Compare commits

..

1 commit

Author SHA1 Message Date
a5b77a3655 feat: agregar página register 2026-04-02 18:55:58 -03:00
67 changed files with 958 additions and 3382 deletions

View file

@ -1,7 +0,0 @@
[*]
end_of_line = lf
charset = utf-8
[*.{js,jsx}]
indent_style = space
indent_size = 2

0
.gitmodules vendored
View file

View file

@ -1,10 +0,0 @@
[
{
"adapter": "JavaScript",
"type": "chrome",
"request": "attach",
"port": 9222,
"label": "Conectar a navegador",
"webRoot": "${GRAM_WORKTREE_ROOT}/src",
},
]

View file

@ -1,3 +0,0 @@
{
"language_servers": ["...", "!biome"],
}

View file

@ -1,3 +0,0 @@
{
"ignorePatterns": []
}

View file

@ -1,94 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true
},
"ignorePatterns": ["dist"],
"overrides": [
{
"files": ["**/*.{js,jsx}"],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
"varsIgnorePattern": "^[A-Z_]"
}
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"react/only-export-components": [
"error",
{
"allowConstantExport": true
}
]
},
"plugins": ["react"],
"env": {
"es2020": true,
"browser": true
}
}
]
}

View file

@ -1,4 +0,0 @@
[global]
flavor = "gfm"
[MD013]
reflow = true

View file

@ -1,2 +0,0 @@
pnpm-lock.yaml
backend/package-lock.json

View file

@ -1,39 +1,6 @@
# Desafío: Pizzería Mamma Mía
# Desafío: Introducción a React
Para el módulo de React del curso de desarrollo full stack con
Para el módulo "Introducción a React" del curso de desarrollo full stack con
JavaScript de Desafío Latam.
Un prototipo de página de pizzería
## Stack
- React 19
- Vite 8
- Zod 4 y Tanstack Form para formularios
## Ejecutando
Este proyecto requiere varias herramientas, pero con
[mise-en-place](https://mise.jdx.dev) deberías poder instalarlas todas (no
olvides de correr `mise trust` en el directorio del proyecto).
[`just`](https://github.com/casey/just) en particular será muy útil para correr
tanto frontend como backend más fácilmente, aunque deberá ser instalado por
separado.
- Instala las dependencias (Asegúrate de tener `mise` instalado):
```bash
just setup
```
> [!TIP]
> Este paso se puede hacer manualmente también, usando `npm i` tanto
> en la raíz del repositorio como en el directorio `backend`. Dicho esto
> recomiendo tener `mise` y `just` para esto,
> ya que lo hace más fácil y rápido
- Inicia el proyecto
```bash
just start
```

View file

@ -1,12 +0,0 @@
POST http://localhost:5000/api/auth/login
{
"email": "test@test.com",
"password": "123123"
}
HTTP 200
[Captures]
token: jsonpath "$.token"
GET http://localhost:5000/api/auth/me
Authorization: Bearer {{token}}
HTTP 200

View file

@ -1 +0,0 @@
JWT_SECRET=increiblementeSecreto

1
backend/.gitignore vendored
View file

@ -1 +0,0 @@
node_modules

View file

@ -1,98 +0,0 @@
# Simple API JWT
API para consumir un servicio de Auth con JWT.
## Instalación
```sh
npm install
```
## Uso
```sh
npm run dev
```
## Endpoints
### Pizzas
```sh
GET /api/pizzas
```
### Pizza (única)
```sh
GET /api/pizzas/:id
```
### Auth
```sh
POST /api/auth/login
POST /api/auth/register
```
body:
```json
{
"email": "test@example.com",
"password": "123123"
}
```
### Checkout & Profile
Esta ruta requiere un token JWT en el header, el token se obtiene en el endpoint
de Auth explicado en el item siguiente (JWT).
Además puedes enviar un carrito con los productos a comprar, esto es solo una
simulación, no se guarda en la base de datos.
```sh
POST /api/checkouts
```
body:
```json
{
"cart": [...]
}
```
Endpoint para obtener el perfil del usuario autenticado. Necesitas enviar el
token JWT en el header.
```sh
GET /api/auth/me
```
## JWT
Para obtener el token JWT, se debe hacer una petición a `/api/auth/login` o a
`/api/auth/register` con el body correspondiente.
El token JWT se debe enviar en el header `Authorization` de la siguiente manera:
```sh
Authorization Bearer token_jwt
```
Ejemplo con fetch:
```js
await fetch("http://localhost:5000/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer token_jwt`,
},
body: JSON.stringify({
cart: carrito,
}),
});
```

View file

@ -1,91 +0,0 @@
import "dotenv/config";
import jwt from "jsonwebtoken";
import { nanoid } from "nanoid";
import { authModel } from "../models/auth.model.js";
import { isValidEmail } from "../utils/validators/email.validate.js";
const login = async (req, res) => {
try {
const { email = "", password = "" } = req.body;
if (!email.trim() || !password.trim()) {
return res.status(400).json({ error: "Email and password are required" });
}
if (!isValidEmail(email)) {
return res.status(400).json({ error: "Invalid email" });
}
if (password.length < 6) {
return res.status(400).json({ error: "Password must be at least 6 characters" });
}
const user = await authModel.getUserByEmail(email);
if (!user) {
return res.status(400).json({ error: "User not found" });
}
if (user.password !== password) {
return res.status(400).json({ error: "Invalid password" });
}
const payload = { email, id: user.id };
const token = jwt.sign(payload, process.env.JWT_SECRET);
return res.json({ email, token });
} catch (error) {
// console.log(error);
return res.status(500).json({ error: "Server error" });
}
};
const register = async (req, res) => {
try {
const { email = "", password = "" } = req.body;
if (!email.trim() || !password.trim()) {
return res.status(400).json({ error: "Email and password are required" });
}
if (!isValidEmail(email)) {
return res.status(400).json({ error: "Invalid email" });
}
if (password.length < 6) {
return res.status(400).json({ error: "Password must be at least 6 characters" });
}
const user = await authModel.getUserByEmail(email);
if (user) {
return res.status(400).json({ error: "User already exists" });
}
const newUser = { email, password, id: nanoid() };
await authModel.addUser(newUser);
const payload = { email, id: newUser.id };
const token = jwt.sign(payload, process.env.JWT_SECRET);
return res.json({ email, token });
} catch (error) {
// console.log(error);
return res.status(500).json({ error: "Server error" });
}
};
const me = async (req, res) => {
try {
const { email } = req.user;
const user = await authModel.getUserByEmail(email);
return res.json({ email, id: user.id });
} catch (error) {
// console.log(error);
return res.status(500).json({ error: "Server error" });
}
};
export const authController = {
login,
register,
me,
};

View file

@ -1,16 +0,0 @@
const create = async (req, res) => {
try {
return res.json({
message: "Checkout successful",
cart: req.body,
user: req.user,
});
} catch (error) {
// console.log(error);
return res.status(500).json({ error: "Server error" });
}
};
export const checkoutController = {
create,
};

View file

@ -1,20 +0,0 @@
import { pizzaModel } from "../models/pizza.model.js";
const readPizzas = async (req, res) => {
const pizzas = await pizzaModel.getPizzas();
res.json(pizzas);
};
const readPizza = async (req, res) => {
const { id } = req.params;
const pizza = await pizzaModel.getPizza(id.toLowerCase());
if (!pizza) {
return res.status(404).json({ message: "Pizza not found" });
}
res.json(pizza);
};
export const pizzaController = {
readPizzas,
readPizza,
};

View file

@ -1,50 +0,0 @@
[
{
"desc": "La pizza napolitana, de masa tierna y delgada pero bordes altos, es la versión propia de la cocina napolitana de la pizza redonda. El término pizza napoletana, por su importancia histórica o regional, se emplea en algunas zonas como sinónimo de pizza tonda.",
"id": "p001",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fpizza-1239077_640_cl.jpg?alt=media&token=6a9a33da-5c00-49d4-9080-784dcc87ec2c",
"ingredients": ["mozzarella", "tomates", "jamón", "orégano"],
"name": "napolitana",
"price": 5950
},
{
"desc": "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
"id": "p002",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fcheese-164872_640_com.jpg?alt=media&token=18b2b821-4d0d-43f2-a1c6-8c57bc388fab",
"ingredients": ["mozzarella", "tomates", "jamón", "choricillo"],
"name": "española",
"price": 7250
},
{
"desc": "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
"id": "p003",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fpizza-1239077_640_com.jpg?alt=media&token=e7cde87a-08d5-4040-ac54-90f6c31eb3e3",
"ingredients": ["mozzarella", "tomates", "salame", "orégano"],
"name": "salame",
"price": 5990
},
{
"desc": "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
"id": "p004",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fpizza-2000595_640_c.jpg?alt=media&token=61325b6e-a1e0-441e-b3b5-7335ba13e8be",
"ingredients": ["mozzarella", "salame", "aceitunas", "champiñones"],
"name": "cuatro estaciones",
"price": 9590
},
{
"desc": "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
"id": "p005",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fpizza-salame.jpg?alt=media&token=ab3d4bf8-01f2-4810-982b-bd7fb6b517b2",
"ingredients": ["mozzarella", "tomates cherry", "bacon", "orégano"],
"name": "bacon",
"price": 6450
},
{
"desc": "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
"id": "p006",
"img": "https://firebasestorage.googleapis.com/v0/b/apis-varias-mias.appspot.com/o/pizzeria%2Fpizza-2000595_640_c.jpg?alt=media&token=61325b6e-a1e0-441e-b3b5-7335ba13e8be",
"ingredients": ["mozzarella", "pimientos", "pollo grillé", "orégano"],
"name": "pollo picante",
"price": 8500
}
]

View file

@ -1,7 +0,0 @@
[
{
"email": "test@test.com",
"password": "123123",
"id": "UYz_2Vy9rNw7uELQ7AZ8D"
}
]

View file

@ -1,24 +0,0 @@
import cors from "cors";
import "dotenv/config";
import express from "express";
import authRoute from "./routes/auth.route.js";
import checkoutRoute from "./routes/checkout.route.js";
import pizzaRoute from "./routes/pizza.route.js";
const app = express();
app.use(express.json());
app.use(cors());
app.use("/api/auth", authRoute);
app.use("/api/pizzas", pizzaRoute);
app.use("/api/checkouts", checkoutRoute);
app.use((_, res) => {
res.status(404).json({ error: "Not Found" });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running on port http://localhost:${PORT}`);
});

View file

@ -1,18 +0,0 @@
import "dotenv/config";
import jwt from "jsonwebtoken";
export const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "No token provided" });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (error) {
// console.log(error);
return res.status(401).send({ error: "Invalid token" });
}
};

View file

@ -1,19 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
const getUserByEmail = async (email) => {
const data = await readFile("db/users.json", "utf-8");
const users = JSON.parse(data);
return users.find((user) => user.email === email);
};
const addUser = async (newUser) => {
const data = await readFile("db/users.json", "utf-8");
const users = JSON.parse(data);
users.push(newUser);
await writeFile("db/users.json", JSON.stringify(users, null, 2));
};
export const authModel = {
getUserByEmail,
addUser,
};

View file

@ -1,16 +0,0 @@
import { readFile } from "node:fs/promises";
const getPizzas = async () => {
const data = await readFile("db/pizzas.json", "utf-8");
return JSON.parse(data);
};
const getPizza = async (id) => {
const pizzas = await getPizzas();
return pizzas.find((pizza) => pizza.id === id);
};
export const pizzaModel = {
getPizzas,
getPizza,
};

1227
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,24 +0,0 @@
{
"name": "simple-api-jwt",
"version": "1.0.0",
"description": "",
"keywords": [],
"license": "ISC",
"author": "bluuweb",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.6"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

View file

@ -1,11 +0,0 @@
import { Router } from "express";
import { authController } from "../controllers/auth.controller.js";
import { authMiddleware } from "../middlewares/auth.middleware.js";
const router = Router();
router.post("/login", authController.login);
router.post("/register", authController.register);
router.get("/me", authMiddleware, authController.me);
export default router;

View file

@ -1,10 +0,0 @@
import { Router } from "express";
import { checkoutController } from "../controllers/checkout.controller.js";
import { authMiddleware } from "../middlewares/auth.middleware.js";
const router = Router();
router.use(authMiddleware);
router.post("/", checkoutController.create);
export default router;

View file

@ -1,9 +0,0 @@
import { Router } from "express";
import { pizzaController } from "../controllers/pizza.controller.js";
const router = Router();
router.get("/", pizzaController.readPizzas);
router.get("/:id", pizzaController.readPizza);
export default router;

View file

@ -1,5 +0,0 @@
export const isValidEmail = (email) => {
const regexEmail =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return regexEmail.test(email);
};

231
biome.json Normal file
View file

@ -0,0 +1,231 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"formatter": { "enabled": true, "indentStyle": "tab" },
"linter": {
"enabled": true,
"rules": { "recommended": false },
"includes": ["**", "!dist"]
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"overrides": [
{
"includes": ["**/*.{js,jsx}"],
"linter": {
"rules": {
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
}
}
},
{
"includes": ["**/*.{js,jsx}"],
"linter": {
"rules": {
"correctness": {
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error"
}
}
}
},
{ "includes": ["**/*.{js,jsx}"], "linter": { "rules": {} } },
{
"includes": ["**/*.{js,jsx}"],
"javascript": {
"globals": [
"onanimationend",
"ongamepadconnected",
"onlostpointercapture",
"onanimationiteration",
"onkeyup",
"onmousedown",
"onanimationstart",
"onslotchange",
"onprogress",
"ontransitionstart",
"onpause",
"onended",
"onpointerover",
"onscrollend",
"onformdata",
"ontransitionrun",
"onanimationcancel",
"ondrag",
"onchange",
"onbeforeinstallprompt",
"onbeforexrselect",
"onmessage",
"ontransitioncancel",
"onpointerdown",
"onabort",
"onpointerout",
"oncuechange",
"ongotpointercapture",
"onscrollsnapchanging",
"onsearch",
"onsubmit",
"onstalled",
"onsuspend",
"onreset",
"onerror",
"onmouseenter",
"ongamepaddisconnected",
"onresize",
"ondragover",
"onbeforetoggle",
"onmouseover",
"onpagehide",
"onmousemove",
"onratechange",
"oncommand",
"onmessageerror",
"onwheel",
"ondevicemotion",
"onauxclick",
"ontransitionend",
"onpaste",
"onpageswap",
"ononline",
"ondeviceorientationabsolute",
"onkeydown",
"onclose",
"onselect",
"onpageshow",
"onpointercancel",
"onbeforematch",
"onpointerrawupdate",
"ondragleave",
"onscrollsnapchange",
"onseeked",
"onwaiting",
"onbeforeunload",
"onplaying",
"onvolumechange",
"ondragend",
"onstorage",
"onloadeddata",
"onfocus",
"onoffline",
"onplay",
"onafterprint",
"onclick",
"oncut",
"onmouseout",
"ondblclick",
"oncanplay",
"onloadstart",
"onappinstalled",
"onpointermove",
"ontoggle",
"oncontextmenu",
"onblur",
"oncancel",
"onbeforeprint",
"oncontextrestored",
"onloadedmetadata",
"onpointerup",
"onlanguagechange",
"oncopy",
"onselectstart",
"onscroll",
"onload",
"ondragstart",
"onbeforeinput",
"oncanplaythrough",
"oninput",
"oninvalid",
"ontimeupdate",
"ondurationchange",
"onselectionchange",
"onmouseup",
"location",
"onkeypress",
"onpointerleave",
"oncontextlost",
"ondrop",
"onsecuritypolicyviolation",
"oncontentvisibilityautostatechange",
"ondeviceorientation",
"onseeking",
"onrejectionhandled",
"onunload",
"onmouseleave",
"onhashchange",
"onpointerenter",
"onmousewheel",
"onunhandledrejection",
"ondragenter",
"onpopstate",
"onpagereveal",
"onemptied"
]
},
"linter": { "rules": { "correctness": { "noUnusedVariables": "error" } } }
}
],
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
}
}

View file

@ -1,3 +0,0 @@
export default {
extends: ["@commitlint/config-conventional"],
};

View file

@ -1,13 +1,13 @@
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(["dist"]),
globalIgnores(['dist']),
{
files: ["**/*.{js,jsx}"],
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
@ -17,13 +17,13 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: "module",
sourceType: 'module',
},
},
rules: {
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
]);
])

View file

@ -1,15 +0,0 @@
mise-task task:
mise r {{ task }}
setup:
mise x -- aube i
cd backend && npm i
start-backend:
cd backend && npm start
start-frontend:
mise r dev
start:
@mprocs

View file

@ -1,16 +1,7 @@
[tools]
aube = "latest"
mprocs = "latest"
oxlint = "latest"
biome = "latest"
pnpm = "latest"
prek = "latest"
oxfmt = "latest"
node = "24"
[tasks.dev]
description = "Arranca el servidor dev"
run = "aube dev"
[tasks.lint]
description = "Lintea los archivos"
run = "oxlint"
run = "pnpm dev"

View file

@ -1,5 +0,0 @@
procs:
frontend:
shell: just start-frontend
backend:
shell: just start-backend

View file

@ -1,35 +1,29 @@
{
"name": "desafio",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "biome check",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-form": "^1.32.0",
"radashi": "^12.7.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@unocss/reset": "^66.6.7",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"unocss": "^66.6.7",
"vite": "^8.0.1"
}
"name": "desafio",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@unocss/reset": "^66.6.7",
"@vitejs/plugin-react-swc": "^4.3.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"unocss": "^66.6.7",
"vite": "^8.0.1"
}
}

View file

@ -1,5 +0,0 @@
[daemons.api]
run = "just start-backend"
[daemons.frontend]
run = "just start-frontend"

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
[[repos]]
hooks = [
{ id = "commitlint", name = "commitlint", language = "system", entry = "mise x -- aubx commitlint -e", stages = [
"commit-msg",
] },
]
repo = "local"

View file

@ -0,0 +1,5 @@
footer {
position: fixed;
bottom: 0;
width: 100%;
}

View file

@ -1,36 +1,19 @@
import { useContext } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import CartProvider from "./context/CartContext.jsx";
import { UserContext } from "./context/UserContext.jsx";
import Footer from "./Footer";
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "./assets/vite.svg";
import heroImg from "./assets/hero.png";
import Navbar from "./Navbar";
import Cart from "./pages/Cart";
import Home from "./pages/Home";
import Login from "./pages/Login";
import NotFound from "./pages/NotFound";
import Pizza from "./pages/Pizza";
import Profile from "./pages/Profile.jsx";
import Register from "./pages/Register";
import "./App.css";
import Home from "./Home";
import Footer from "./Footer";
function App() {
const { token } = useContext(UserContext);
return (
<CartProvider>
<>
<Navbar />
<main className="pb-4">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={token ? <Navigate to="/" /> : <Login />} />
<Route path="/register" element={token ? <Navigate to="/" /> : <Register />} />
<Route path="/pizza/:id" element={<Pizza />} />
<Route path="/cart" element={<Cart />} />
<Route path="/profile" element={token ? <Profile /> : <Navigate to="/login" />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Home />
<Footer />
</CartProvider>
</>
);
}

View file

@ -1,6 +1,6 @@
const Footer = () => (
<footer className="bg-gray-700 flex justify-center items-center p-4 text-white">
© 2021 - Pizzería Mamma Mia! - Todos los derechos reservados
</footer>
<footer className="bg-gray-700 flex justify-center items-center p-4 text-white">
© 2021 - Pizzería Mamma Mia! - Todos los derechos reservados
</footer>
);
export default Footer;

View file

@ -1,12 +1,12 @@
import headerUrl from "./assets/header.jpg";
const Header = () => (
<header
style={{ "--img-url": `url(${headerUrl})` }}
className="h-[20dvh] w-full bg-[image:var(--img-url)] flex flex-col justify-center items-center text-white"
>
<p className="text-5xl font-bold">Mamma mia!</p>
<p className="text-3xl">¡La mejor pizza!</p>
</header>
<header
style={{ "--img-url": `url(${headerUrl})` }}
className="h-[20dvh] w-full bg-[image:var(--img-url)] flex flex-col justify-center items-center text-white"
>
<p className="text-5xl font-bold">Mamma mia!</p>
<p className="text-3xl">¡La mejor pizza!</p>
</header>
);
export default Header;

33
src/Home.jsx Normal file
View file

@ -0,0 +1,33 @@
import Header from "./Header";
import napolitana from "./assets/napolitana.jpg";
import española from "./assets/española.jpg";
import pepperoni from "./assets/pepperoni.jpg";
import CardPizza from "./components/CardPizza";
const Home = () => (
<section id="home" className="w-full flex flex-col gap-4 pb-4">
<Header />
<div className="flex gap-4">
<CardPizza
name="Napolitana"
price={5950}
ingredients={["mozzarella", "tomates", "jamón", "orégano"]}
img={napolitana}
/>
<CardPizza
name="Española"
price={6950}
ingredients={["mozzarella", "gorgonzola", "parmesano", "provolone"]}
img={española}
/>
<CardPizza
name="Pepperoni"
price={6950}
ingredients={["mozzarella", "pepperoni", "orégano"]}
img={pepperoni}
/>
</div>
</section>
);
export default Home;

View file

@ -1,11 +1,8 @@
nav {
font-size: 1rem;
width: 100%;
position: sticky;
top: 0;
font-size: 1rem;
width: 100%;
}
nav button,
a {
--uno: "bg-none border-none hover:bg-white/20 rounded-md p-2";
nav button {
--uno: "bg-none border-none hover:bg-white/20 rounded-md p-2";
}

View file

@ -1,35 +1,29 @@
import { CartContext } from "./context/CartContext";
import { UserContext } from "./context/UserContext";
import "./Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
const Navbar = () => {
const { getTotal } = useContext(CartContext);
const { logout, token } = useContext(UserContext);
const total = getTotal();
const total = 25000;
const token = false;
return (
<nav className="bg-green-700 text-white flex items-center justify-between gap-4">
<div className="flex items-center">
<Link to="/" className="font-bold">
Pizzería Mamma Mía!
</Link>
{token ? (
<>
<Link to="/profile">Profile</Link>
<button onClick={logout}>Logout</button>
</>
) : (
<>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
</>
)}
</div>
<Link to="/cart">Total: ${total.toLocaleString("es-CL")}</Link>
</nav>
);
return (
<nav className="bg-green-700 text-white flex items-center justify-between gap-4">
<div className="flex items-center">
<p className="font-bold">Pizzería Mamma Mía!</p>
<button>Home</button>
{token ? (
<>
<button>Profile</button>
<button>Logout</button>
</>
) : (
<>
<button>Login</button>
<button>Register</button>
</>
)}
</div>
<button>Total: ${total.toLocaleString("es-CL")}</button>
</nav>
);
};
export default Navbar;

42
src/Register.jsx Normal file
View file

@ -0,0 +1,42 @@
import { useState } from "react";
const Register = () => {
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [confirmPass, setConfirmPass] = useState("");
return (
<div>
<p>Usuario</p>
<input type="text" onChange={(ev) => setUser(ev.target.value)} />
<p>Contraseña</p>
<input type="password" onChange={(ev) => setPass(ev.target.value)} />
<p>Confirmar contraseña</p>
<input
type="password"
onChange={(ev) => setConfirmPass(ev.target.value)}
/>
<br />
<button
className="p-2 rounded-md bg-teal-400"
onClick={() => {
if (pass.length < 6) {
alert("La contraseña debe tener por lo menos 6 caracteres");
return;
}
if (pass !== confirmPass) {
alert("Las contraseñas no coinciden");
return;
}
if (user.length === 0) {
alert("Se requiere un nombre de usuario");
}
alert("Autenticacion exitosa");
}}
>
Registrarse
</button>
</div>
);
};
export default Register;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -1,4 +1,4 @@
.card-pizza {
--uno: "rounded-md shadow-sm bg-gray-200 flex flex-col w-128 h-fit pb-2";
overflow: hidden;
--uno: "rounded-md shadow-sm bg-gray-200 flex flex-col w-128";
overflow: hidden;
}

View file

@ -1,40 +1,25 @@
import { useContext } from "react";
import "./CardPizza.css";
import * as R from "radashi";
import { Link } from "react-router-dom";
import { CartContext } from "../context/CartContext";
/**
*
* @param {{img: string, name: string, desc: string, ingredients: string[], price: number}} props
* @param {{img: string, name: string, ingredients: string[], price: number}} props
* @returns
*/
const CardPizza = (props) => {
const { addToCart } = useContext(CartContext);
return (
<article className="card-pizza">
<img src={props.img} />
<div>
<h2 className="font-bold">Pizza {props.name}</h2>
<p className="text-gray-500">{props.desc}</p>
<p className="font-medium font-italic">Ingredientes:</p>
<ul>
{props.ingredients.map((i, index) => (
<li key={index}>- {i}</li>
))}
</ul>
</div>
<p className="font-bold text-green-700">${props.price.toLocaleString("es-CL")}</p>
<div className="flex gap-4">
<Link to={`/pizza/${props.id}`} className="border-black border-2 rounded-md px-4">
Ver más
</Link>
<button onClick={() => addToCart(props)} className="bg-black text-white rounded-md px-4">
Añadir
</button>
</div>
</article>
);
};
const CardPizza = (props) => (
<article className="card-pizza">
<img src={props.img} />
<div>
<h2 className="font-bold">Pizza {props.name}</h2>
<p>Ingredientes: {props.ingredients.join(", ")}</p>
</div>
<p className="font-bold text-green-700">
${props.price.toLocaleString("es-CL")}
</p>
<div className="flex gap-4">
<button className="border-black border-2 rounded-md px-4">Ver más</button>
<button className="bg-black text-white rounded-md px-4">Añadir</button>
</div>
</article>
);
export default CardPizza;

View file

@ -1,28 +0,0 @@
import { createContext, useState } from "react";
import * as R from "radashi";
export const CartContext = createContext();
const CartProvider = ({ children }) => {
const [cart, setCart] = useState([]);
const getTotal = () => {
return cart.reduce((acc, it) => acc + it.price * it.count, 0);
};
const addToCart = (pizzaToAdd) => {
setCart((cart) => {
const pizza = cart.find((p) => p.id === pizzaToAdd.id);
if (pizza) {
return cart.map((p) => (p.id === pizzaToAdd.id ? { ...p, count: p.count + 1 } : p));
} else {
return [...cart, { ...R.omit(pizzaToAdd, ["key"]), count: 1 }];
}
});
};
return (
<CartContext.Provider value={{ cart, setCart, addToCart, getTotal }}>
{children}
</CartContext.Provider>
);
};
export default CartProvider;

View file

@ -1,71 +0,0 @@
import { createContext, useState } from "react";
export const UserContext = createContext();
const UserProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem("loginToken"));
const [email, setEmail] = useState(null);
const storeData = (email, token) => {
setEmail(email);
setToken(token);
localStorage.setItem("loginToken", token);
};
const login = async (user, password) => {
const res = await fetch("http://localhost:5000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: user, password }),
});
if (!res.ok) {
throw new Error("Error al iniciar sesión");
}
const data = await res.json();
storeData(email, data.token);
};
const register = async (user, password) => {
const res = await fetch("http://localhost:5000/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: user, password }),
});
if (!res.ok) {
throw new Error("Error al registrarse");
}
const data = await res.json();
storeData(email, data.token);
};
const logout = () => {
localStorage.removeItem("loginToken");
setToken(null);
setEmail(null);
};
const getProfile = async () => {
const res = await fetch("http://localhost:5000/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error("Error al obtener perfil", res.status);
}
const data = await res.json();
return data;
};
return (
<UserContext.Provider
value={{
token,
setToken,
email,
setEmail,
login,
register,
getProfile,
logout,
}}
>
{children}
</UserContext.Provider>
);
};
export default UserProvider;

View file

@ -1,11 +1,4 @@
body {
margin: 0;
padding: 0;
}
#root {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 2rem 1fr 2rem;
min-height: 100dvh;
margin: 0;
padding: 0;
}

View file

@ -2,17 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import "@unocss/reset/tailwind.css";
import "virtual:uno.css";
import { BrowserRouter } from "react-router-dom";
import UserProvider from "./context/UserContext.jsx";
import "@unocss/reset/tailwind.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<UserProvider>
<App />
</UserProvider>
</BrowserRouter>
<App />
</StrictMode>,
);

View file

@ -1,91 +0,0 @@
import { useContext, useState } from "react";
import { CartContext } from "../context/CartContext";
import { UserContext } from "../context/UserContext";
const Cart = () => {
const { token } = useContext(UserContext);
const { cart, setCart, getTotal } = useContext(CartContext);
const [success, setSuccess] = useState(false);
return (
<>
<h1 className="text-4xl font-bold">Carrito</h1>
{!success ? (
<>
<div id="cartContainer" className="flex gap-4 flex-col">
{cart.map((pizza) => (
<div
className="border-3 border-lime-300 rounded-md flex gap-4 overflow-hidden"
key={pizza.id}
>
<img src={pizza.img} className="w-32 rounded-r-sm" />
<div>
<h2>Pizza {pizza.name}</h2>
<div className="flex gap-4">
<button
onClick={() => {
setCart((cart) =>
cart.map((p) =>
p.id === pizza.id
? { ...p, count: p.count + 1 }
: p,
),
);
}}
className="bg-blue-500 px-4 text-white rounded-md"
>
+
</button>
<p className="text-grey-500">{pizza.count}</p>
<button
onClick={() => {
setCart((cart) =>
cart
.map((p) =>
p.id === pizza.id
? { ...p, count: p.count - 1 }
: p,
)
.filter((p) => p.count > 0),
);
}}
className="bg-red-500 px-4 text-white rounded-md"
>
-
</button>
</div>
</div>
</div>
))}
<p className="text-3xl">
Total:
<strong>{` $${getTotal().toLocaleString("es-CL")}`}</strong>
</p>
</div>
<button
type="button"
disabled={!token}
className="bg-black text-white rounded-md p-2 text-lg hover:bg-gray-500 disabled:bg-gray-200"
onClick={async () => {
const res = await fetch("http://localhost:5000/api/checkouts", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ cart }),
});
if (!res.ok) {
console.error(res);
return;
}
setSuccess(true);
}}
>
Pagar
</button>
</>
) : (
<p class="text-4xl text-teal-500">¡Compra hecha con éxito!</p>
)}
</>
);
};
export default Cart;

View file

@ -1,31 +0,0 @@
import { useEffect, useState } from "react";
import española from "../assets/española.jpg";
import napolitana from "../assets/napolitana.jpg";
import pepperoni from "../assets/pepperoni.jpg";
import CardPizza from "../components/CardPizza";
import Header from "../Header";
const Home = () => {
const [pizzas, setPizzas] = useState([]);
const fetchPizzas = async () => {
const url = "http://localhost:5000/api/pizzas";
const res = await fetch(url);
const data = await res.json();
setPizzas(data);
};
useEffect(() => {
fetchPizzas();
}, []);
return (
<section id="home" className="w-dvw flex flex-col overflow-auto gap-4 pb-4">
<Header />
<div className="flex gap-4">
{pizzas.map((pizza) => (
<CardPizza key={pizza.id} {...pizza} />
))}
</div>
</section>
);
};
export default Home;

View file

@ -1,91 +0,0 @@
// Librería recomendada en tutoría
import { useForm } from "@tanstack/react-form";
import { useContext } from "react";
// Librería recomendada en tutoría
import { z } from "zod";
import { UserContext } from "../context/UserContext";
// Esquema de validación de login
const loginSchema = z.object({
user: z
.email({ error: "Email inválido" })
.nonempty({ error: "Se requiere un email" }),
password: z
.string()
.min(6, { error: "La contraseña debe tener al menos 6 caracteres" }),
});
const Login = () => {
const { login } = useContext(UserContext);
const form = useForm({
defaultValues: { user: "", password: "" },
validators: { onChange: loginSchema },
onSubmit: async ({ value: { user, password } }) => {
try {
await login(user, password);
} catch (error) {
console.error(error);
alert("Error de login");
}
},
});
/**
* @type {React.SubmitEventHandler<HTMLFormElement>}
*/
return (
<form
onSubmit={(ev) => {
ev.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="user"
children={(field) => {
return (
<>
<p>Usuario</p>
<input
type="text"
className="bg-gray-200"
value={field.value}
onChange={(ev) => field.handleChange(ev.target.value)}
/>
{!field.state.meta.isValid && (
<p className="text-red-300">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</p>
)}
</>
);
}}
/>
<p>Contraseña</p>
<form.Field
name="password"
children={(field) => {
return (
<>
<input
type="password"
className="bg-gray-200"
value={field.value}
onChange={(ev) => field.handleChange(ev.target.value)}
/>
{!field.state.meta.isValid && (
<p className="text-red-300">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</p>
)}
</>
);
}}
/>
<button className="p-2 rounded-md bg-teal-400" type="submit">
Iniciar sesión
</button>
</form>
);
};
export default Login;

View file

@ -1,21 +0,0 @@
import { Link } from "react-router-dom";
const NotFound = () => {
return (
<section className="flex gap-4 flex-col w-fit">
<h1 className="text-4xl">Error 404</h1>
<p>
¿No sabes qué es eso? Significa que esta página no existe. Vamos, volvamos a la página
principal
</p>
<Link
to="/"
className="p-2 bg-green-700 text-white w-fit hover:bg-green-300 hover:text-black"
>
Ir al inicio
</Link>
</section>
);
};
export default NotFound;

View file

@ -1,42 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { CartContext } from "../context/CartContext";
const Pizza = () => {
const { id } = useParams();
const [pizza, setPizza] = useState(null);
const [error, setError] = useState(null);
const { addToCart } = useContext(CartContext);
const fetchPizza = async () => {
const url = `http://localhost:5000/api/pizzas/${id}`;
const res = await fetch(url);
const data = await res.json();
setPizza(data);
};
useEffect(() => {
fetchPizza().catch(setError);
});
return pizza && !error ? (
<section id="pizza">
<h1 className="text-4xl font-bold">Pizza {pizza.name}</h1>
<p className="text-green-700 font-bold text-2xl">${pizza.price.toLocaleString("es-CL")}</p>
<img src={pizza.img} className="rounded-md" />
<p>{pizza.desc}</p>
<h2 className="text-3xl">Ingredientes</h2>
<ul>
{pizza.ingredients.map((i, index) => (
<li key={index}>- {i}</li>
))}
</ul>
<button onClick={() => addToCart(pizza)} className="text-white bg-black p-2 rounded-md">
Añadir al carrito
</button>
</section>
) : (
<>
<h1 className="text-red font-bold">Error al obtener pizza: {error}</h1>
</>
);
};
export default Pizza;

View file

@ -1,33 +0,0 @@
import { useContext, useState } from "react";
import { UserContext } from "../context/UserContext";
import { useEffect } from "react";
const Profile = () => {
const [email, setEmail] = useState("");
const { getProfile, logout } = useContext(UserContext);
useEffect(() => {
getProfile()
.then((data) => {
setEmail(data.email);
})
.catch((error) => {
console.error(error);
});
});
return (
<div>
<p>
<strong>Mail: </strong>
{email}
</p>
<button
onClick={logout}
className="bg-green-700 hover:bg-green-300 text-white hover:text-black"
>
Cerrar sesión
</button>
</div>
);
};
export default Profile;

View file

@ -1,100 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { useContext, useState } from "react";
import { z } from "zod";
import { UserContext } from "../context/UserContext";
const registerSchema = z
.object({
user: z.email().nonempty({ error: "Se requiere un email" }),
password: z.string().min(6, { error: "la contraseña debe ser de al menos 6 caracteres" }),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
error: "las contraseñas no coinciden",
path: ["confirmPassword"],
});
const Register = () => {
const { register } = useContext(UserContext);
const form = useForm({
defaultValues: { user: "", password: "", confirmPassword: "" },
validators: { onChange: registerSchema },
onSubmit: ({ value: { user, password, confirmPassword } }) => {
try {
register(user, password);
} catch (error) {
alert("Error de registro");
}
},
});
return (
<form
onSubmit={(ev) => {
ev.preventDefault();
form.handleSubmit();
}}
>
<p>Usuario</p>
<form.Field
name="user"
children={(field) => (
<>
<input
type="text"
className="bg-gray-200"
value={field.value}
onChange={(ev) => field.handleChange(ev.target.value)}
/>
{!field.state.meta.isValid && (
<p className="text-red-300">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</p>
)}
</>
)}
/>
<p>Contraseña</p>
<form.Field
name="password"
children={(field) => (
<>
<input
type="password"
className="bg-gray-200"
onChange={(ev) => field.handleChange(ev.target.value)}
/>
{!field.state.meta.isValid && (
<p className="text-red-300">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</p>
)}
</>
)}
/>
<p>Confirmar contraseña</p>
<form.Field
name="confirmPassword"
children={(field) => (
<>
<input
type="password"
className="bg-gray-200"
onChange={(ev) => field.handleChange(ev.target.value)}
/>
{!field.state.meta.isValid && (
<p className="text-red-300">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</p>
)}
</>
)}
/>
<br />
<button className="p-2 rounded-md bg-teal-400" type="submit">
Registrarse
</button>
</form>
);
};
export default Register;

View file

@ -1,82 +0,0 @@
import imgBacon from "./assets/bacon.jpg";
import imgCuatroEstaciones from "./assets/cuatro-estaciones.jpg";
import imgEspanola from "./assets/española.jpg";
import imgNapolitana from "./assets/napolitana.jpg";
import imgSalame from "./assets/pepperoni.jpg";
import imgPolloPicante from "./assets/pollo-picante.jpg";
export const pizzas = [
{
desc: "La pizza napolitana, de masa tierna y delgada pero bordes altos, es la versión propia de la cocina napolitana de la pizza redonda. El término pizza napoletana, por su importancia histórica o regional, se emplea en algunas zonas como sinónimo de pizza tonda.",
id: "P001",
img: imgNapolitana,
ingredients: ["mozzarella", "tomates", "jamón", "orégano"],
name: "napolitana",
price: 5950,
},
{
desc: "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
id: "P002",
img: imgEspanola,
ingredients: ["mozzarella", "tomates", "jamón", "choricillo"],
name: "española",
price: 7250,
},
{
desc: "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
id: "P003",
img: imgSalame,
ingredients: ["mozzarella", "tomates", "salame", "orégano"],
name: "salame",
price: 5990,
},
{
desc: "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
id: "P004",
img: imgCuatroEstaciones,
ingredients: ["mozzarella", "salame", "aceitunas", "champiñones"],
name: "cuatro estaciones",
price: 9590,
},
{
desc: "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
id: "P005",
img: imgBacon,
ingredients: ["mozzarella", "tomates cherry", "bacon", "orégano"],
name: "bacon",
price: 6450,
},
{
desc: "La pizza es una preparación culinaria que consiste en un pan plano, habitualmente de forma circular, elaborado con harina de trigo, levadura, agua y sal (a veces aceite de oliva) que comúnmente se cubre con salsa de tomate, queso y otros muchos ingredientes, y que se hornea a alta temperatura, tradicionalmente en un horno de leña.",
id: "P006",
img: imgPolloPicante,
ingredients: ["mozzarella", "pimientos", "pollo grillé", "orégano"],
name: "pollo picante",
price: 8500,
},
];
// Simulación de un carrito de compras
export const pizzaCart = [
{
id: "P001",
name: "napolitana",
price: 5950,
count: 1,
img: imgNapolitana,
},
{
id: "P002",
name: "española",
price: 7250,
count: 1,
img: imgEspanola,
},
{
id: "P003",
name: "salame",
price: 5990,
count: 1,
img: imgSalame,
},
];

View file

@ -1,6 +1,7 @@
import { defineConfig, presetWind3, transformerDirectives } from "unocss";
import { transformerDirectives } from "unocss";
import { defineConfig, presetWind3 } from "unocss";
export default defineConfig({
presets: [presetWind3()],
transformers: [transformerDirectives()],
presets: [presetWind3()],
transformers: [transformerDirectives()],
});

View file

@ -1,8 +1,8 @@
import react from "@vitejs/plugin-react";
import unocss from "unocss/vite";
import { defineConfig } from "vite";
import react_swc from "@vitejs/plugin-react-swc";
import unocss from "unocss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react, unocss()],
plugins: [react_swc(), unocss()],
});