Compare commits

..

29 commits

Author SHA1 Message Date
617c0e82e1 feat: agregar checkout y unos tests de API 2026-05-18 12:38:56 -04:00
6a9b719226 feat: agregar request para profile 2026-05-18 11:59:14 -04:00
a42e769c3e feat: actualizar README 2026-05-18 10:24:31 -04:00
e393e7d95d chore(fmt): aplicar oxlint 2026-05-18 10:23:12 -04:00
fab315eece chore: agregar mprocs 2026-05-18 10:13:46 -04:00
e2cdab8b11 feat: agregar login y register funcionales 2026-05-18 09:44:14 -04:00
e02de65ec2 chore: cambiar a oxlint y oxfmt 2026-05-18 09:43:44 -04:00
9b2a8260fe fix: arreglar condición de ruta login y register 2026-05-18 09:42:14 -04:00
69deb9c18d feat: redirigir usuarios registrados a Home en login y register 2026-05-10 10:56:39 -04:00
7c2c8e7470 chore(just): cambiar a aube 2026-04-29 22:12:29 -04:00
224a3d89a4 feat!: cambiar a aube, agregar context para el carrito 2026-04-29 22:10:21 -04:00
e6e2f40bb2 fix: agregar perfil al router 2026-04-25 10:36:31 -04:00
d6c48759db feat: agregar profile y aplanar submódulo 2026-04-25 10:27:29 -04:00
8906d7a970 feat!: agregar página Not found 2026-04-24 13:45:05 -04:00
9271ab2333 feat!: agregar React Router 2026-04-22 12:11:24 -04:00
4fe2c93a06 refactor!: move router pages to another dir and add commitlint 2026-04-22 11:39:20 -04:00
7c464788dc dev: add Pitchfork for daemons 2026-04-19 15:54:08 -04:00
2d6ddcdcb6 fix: mostrar descripción pizza 2026-04-15 10:10:24 -04:00
75be7dd3d6 chore(just): adjust recipes 2026-04-14 22:32:10 -04:00
8eba633d93 feat: agregar página de pizza 2026-04-14 22:23:32 -04:00
8134f3af38 feat: agregar backend 2026-04-14 21:52:43 -04:00
1feb11a446 fix: agregar imágenes pizzas 2026-04-10 11:08:59 -04:00
2200c5e0d2 feat(cart): agregar botón de pago 2026-04-10 10:56:08 -04:00
8fd072af8b chore(lint): usar Biome para linting 2026-04-10 10:27:51 -04:00
cc505c5a61 feat: agregar imágenes al carrito 2026-04-09 15:04:24 -04:00
9b1f471ebc fix: Hacer que las cards se ajusten al contenido 2026-04-08 14:06:54 -04:00
e82cd0d475 feat: agregar total al carrito 2026-04-08 13:48:11 -04:00
2436eb3747 feat: agregar carrito y pizzas.js 2026-04-08 11:12:34 -04:00
43de8349ae refactor: hacer el código mas DRY 2026-04-02 20:08:27 -03:00
67 changed files with 3381 additions and 957 deletions

7
.editorconfig Normal file
View file

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

0
.gitmodules vendored Normal file
View file

10
.gram/debug.jsonc Normal file
View file

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

3
.gram/settings.jsonc Normal file
View file

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

3
.oxfmtrc.json Normal file
View file

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

94
.oxlintrc.json Normal file
View file

@ -0,0 +1,94 @@
{
"$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
}
}
]
}

4
.rumdl.toml Normal file
View file

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

2
.tokeignore Normal file
View file

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

View file

@ -1,6 +1,39 @@
# Desafío: Introducción a React
# Desafío: Pizzería Mamma Mía
Para el módulo "Introducción a React" del curso de desarrollo full stack con
Para el módulo de 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
```

12
api-tests/profile.hurl Normal file
View file

@ -0,0 +1,12 @@
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

1
backend/.env Normal file
View file

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

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

98
backend/README.md Normal file
View file

@ -0,0 +1,98 @@
# 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

@ -0,0 +1,91 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,20 @@
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,
};

50
backend/db/pizzas.json Normal file
View file

@ -0,0 +1,50 @@
[
{
"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
}
]

7
backend/db/users.json Normal file
View file

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

24
backend/index.js Normal file
View file

@ -0,0 +1,24 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,16 @@
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 Normal file

File diff suppressed because it is too large Load diff

24
backend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"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

@ -0,0 +1,11 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,9 @@
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

@ -0,0 +1,5 @@
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);
};

View file

@ -1,231 +0,0 @@
{
"$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" } }
}
}

3
commitlint.config.js Normal file
View file

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

View file

@ -1,13 +1,13 @@
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'
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";
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_]" }],
},
},
])
]);

15
justfile Normal file
View file

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

5
mprocs.yaml Normal file
View file

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

View file

@ -1,29 +1,35 @@
{
"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"
}
"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"
}
}

5
pitchfork.toml Normal file
View file

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

File diff suppressed because it is too large Load diff

7
prek.toml Normal file
View file

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

View file

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

View file

@ -1,19 +1,36 @@
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 { useContext } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import Home from "./Home";
import CartProvider from "./context/CartContext.jsx";
import { UserContext } from "./context/UserContext.jsx";
import Footer from "./Footer";
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";
function App() {
const { token } = useContext(UserContext);
return (
<>
<CartProvider>
<Navbar />
<Home />
<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>
<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;

View file

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

View file

@ -1,29 +1,35 @@
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 total = 25000;
const token = false;
const { getTotal } = useContext(CartContext);
const { logout, token } = useContext(UserContext);
const total = getTotal();
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>
);
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>
);
};
export default Navbar;

View file

@ -1,42 +0,0 @@
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;

BIN
src/assets/bacon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

Before

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";
overflow: hidden;
--uno: "rounded-md shadow-sm bg-gray-200 flex flex-col w-128 h-fit pb-2";
overflow: hidden;
}

View file

@ -1,25 +1,40 @@
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, ingredients: string[], price: number}} props
* @param {{img: string, name: string, desc: string, ingredients: string[], price: number}} props
* @returns
*/
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>
);
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>
);
};
export default CardPizza;

View file

@ -0,0 +1,28 @@
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

@ -0,0 +1,71 @@
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,4 +1,11 @@
body {
margin: 0;
padding: 0;
margin: 0;
padding: 0;
}
#root {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 2rem 1fr 2rem;
min-height: 100dvh;
}

View file

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

91
src/pages/Cart.jsx Normal file
View file

@ -0,0 +1,91 @@
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;

31
src/pages/Home.jsx Normal file
View file

@ -0,0 +1,31 @@
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;

91
src/pages/Login.jsx Normal file
View file

@ -0,0 +1,91 @@
// 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;

21
src/pages/NotFound.jsx Normal file
View file

@ -0,0 +1,21 @@
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;

42
src/pages/Pizza.jsx Normal file
View file

@ -0,0 +1,42 @@
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;

33
src/pages/Profile.jsx Normal file
View file

@ -0,0 +1,33 @@
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;

100
src/pages/Register.jsx Normal file
View file

@ -0,0 +1,100 @@
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;

82
src/pizzas.js Normal file
View file

@ -0,0 +1,82 @@
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,7 +1,6 @@
import { transformerDirectives } from "unocss";
import { defineConfig, presetWind3 } from "unocss";
import { defineConfig, presetWind3, transformerDirectives } from "unocss";
export default defineConfig({
presets: [presetWind3()],
transformers: [transformerDirectives()],
presets: [presetWind3()],
transformers: [transformerDirectives()],
});

View file

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