This commit is contained in:
jose-rZM
2025-12-15 11:42:17 +01:00
commit c3f7af7379
33 changed files with 5004 additions and 0 deletions

199
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,199 @@
pipeline {
agent none
options {
timestamps()
}
environment {
CI = 'true'
NODE_OPTIONS = '--max_old_space_size=2048'
APP_VERSION = "1.0.${BUILD_NUMBER}"
}
stages {
stage('Init') {
agent any
steps {
script {
env.COMMIT_AUTHOR = sh(
script: "git show -s --format=%an HEAD",
returnStdout: true
).trim()
env.COMMIT_SHORT = sh(
script: "git rev-parse --short HEAD",
returnStdout: true
).trim()
}
echo "Last commit: ${env.COMMIT_AUTHOR}"
}
}
/* =========================
BACKEND (Python)
========================= */
stage('Backend: deps') {
agent {
docker {
image 'python:3.12'
args '-u root'
}
}
steps {
dir('backend') {
sh '''
set -e
python --version
python -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements-dev.txt
'''
}
}
}
stage('Backend: lint & test') {
agent {
docker {
image 'python:3.12'
args '-u root'
}
}
steps {
dir('backend') {
sh '''
set -e
. .venv/bin/activate
ruff check app tests
pytest
'''
}
}
}
/* =========================
FRONTEND (Node)
========================= */
stage('Frontend: deps') {
agent {
docker {
image 'node:20'
}
}
steps {
dir('frontend') {
sh '''
set -e
node --version
npm --version
npm install --no-progress --no-audit --prefer-offline
'''
}
}
}
stage('Frontend: check & test') {
agent {
docker {
image 'node:20'
}
}
steps {
dir('frontend') {
sh '''
set -e
npm run check
'''
}
}
}
stage('Frontend: build') {
agent {
docker {
image 'node:20'
}
}
steps {
dir('frontend') {
sh '''
set -e
npm run build
'''
}
}
}
/* =========================
DOCKER
========================= */
stage('Docker: build images') {
when {
expression {
fileExists('backend/Dockerfile') &&
fileExists('frontend/Dockerfile')
}
}
agent any
steps {
sh '''
set -e
docker version
docker build \
--build-arg APP_VERSION=${APP_VERSION} \
--build-arg GIT_COMMIT=${COMMIT_SHORT} \
--build-arg COMMIT_AUTHOR="${COMMIT_AUTHOR}" \
--build-arg BUILD_NUMBER=${BUILD_NUMBER} \
-t cafeteria-backend:${BUILD_NUMBER} ./backend
docker build \
-t cafeteria-frontend:${BUILD_NUMBER} ./frontend
'''
}
}
stage('Deploy frontend & backend') {
agent any
steps {
sh '''
set -e
echo "Deploying build ${BUILD_NUMBER}"
docker rm -f cafeteria-backend cafeteria-frontend 2>/dev/null || true
docker run -d \
--name cafeteria-backend \
-p 8000:8000 \
cafeteria-backend:${BUILD_NUMBER}
docker run -d \
--name cafeteria-frontend \
-p 3000:80 \
cafeteria-frontend:${BUILD_NUMBER}
'''
}
}
}
post {
always {
script {
node {
cleanWs()
}
}
}
}
}

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# Taller CI/CD con Jenkins - Proyecto base
- `backend/`: API con FastAPI.
- `frontend/`: interfaz en Svelte (Vite).
## Requisitos locales
- Python 3.11+ y `pip`
- Node 18+ y `npm`
## Backend (FastAPI)
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Endpoints:
- `GET /health` estado.
- `GET /menu` devuelve el menú del día.
- `GET /prices` lista de precios aleatorios.
- `GET /prices/{item}` precio aleatorio para un item concreto.
Tests y lint:
```bash
cd backend
pip install -r requirements-dev.txt
pytest
ruff check app tests
```
Docker:
```bash
cd backend
docker build -t cafeteria-backend .
docker run -p 8000:8000 cafeteria-backend
```
## Frontend (Svelte)
```bash
cd frontend
npm install
npm run dev -- --host --port 5173
```
Tests, lint/check:
```bash
cd frontend
npm test # no, no voy a hacer tests de frontend
npm run check
```
Docker (sirve con nginx):
```bash
cd frontend
docker build -t cafeteria-frontend .
docker run -p 8080:80 cafeteria-frontend
```
## Jenkinsfile (pipeline)
Incluido `Jenkinsfile` declarativo para ejecutar en un agente cualquiera con Python 3.11+, Node 18+ y Docker:
- Backend: crea entorno virtual, instala `requirements-dev`, pasa `ruff` y `pytest`.
- Frontend: `npm install`, `npm run check` y `npm test`, luego `npm run build`.
- Docker: construye imágenes `cafeteria-backend` y `cafeteria-frontend` si existen los Dockerfile.
- Se despliegan las imágenes.
- En la aplicación se recupera el último commit y el autor.

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.venv
__pycache__
.pytest_cache
.ruff_cache
*.pyc
.env
.env.*

8
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__
.venv
.env
*.pyc
*.log
.pytest_cache/
.ruff_cache/
*.py[cod]

38
backend/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM python:3.11-slim AS base
# ---- Build args (desde Jenkins) ----
ARG APP_VERSION=dev
ARG GIT_COMMIT=local
ARG COMMIT_AUTHOR=local
ARG BUILD_NUMBER=
# ---- Runtime env ----
ENV APP_VERSION=${APP_VERSION} \
GIT_COMMIT=${GIT_COMMIT} \
COMMIT_AUTHOR=${COMMIT_AUTHOR} \
BUILD_NUMBER=${BUILD_NUMBER} \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100
LABEL app.version="${APP_VERSION}" \
git.commit="${GIT_COMMIT}" \
git.author="${COMMIT_AUTHOR}" \
build.number="${BUILD_NUMBER}"
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,68 @@
{
"starters": [
"Ensalada de pimiento asado",
"Ensalada de yogurt con pavo",
"Ensalada mixta",
"Macarrones con salsa de tomate y queso",
"Ensaladilla rusa",
"Paella de verdura",
"Pasta con tomate",
"Ensalada César"
],
"mains": {
"fish": [
"Pescado al vapor",
"Gallo san Pedro",
"Pescado al limón",
"Pescado al vapor"
],
"others": [
"Arroz salteado con verduras",
"Pollo asado",
"San jacobo de pavo",
"Cinta de lomo adobata con pimientos",
"Croquetas de bacalao",
"Flamenquín de pollo",
"Albóndigas con tomate"
],
"garnish": [
"Patatas panaderas",
"Patatas fritas",
"Arroz salteado con verdura",
"Brocoli al vapor",
"Patatas gajo"
]
},
"desserts": [
"Natilla con galleta",
"\"Frutas\" varias",
"Café",
"Plátano",
"Mousse de limón",
"Mousse de café",
"Compota (¿alguien se ha pedido esto alguna vez?)"
],
"notes": [
"Aquí al lado tienes la BP, los bocatas están muy bien.",
"¿No has pensado en comer en Ciencias?",
"O un bocata de estos fríos y lacios de la máquina..."
],
"espetos_tips": [
"El bocata pollo completo en la BP son solo 3.9 €.",
"El menú de ciencias de hoy tiene muy buena pinta, solo digo eso.",
"Aquí cerca hay un sitio que vende tuppers muy buenos."
],
"alternatives": {
"title": "Alternativa para el almuerzo",
"items": [
"Pizza (congelada)",
"¿Campero? No sé, dos panes con algo en medio"
],
"price": 5
},
"university_deal": {
"old_price": 4.5,
"current_price": 5.5,
"note": "jaja ya no tienes descuento de estudiante por los recortes de la UMA"
}
}

View File

@@ -0,0 +1,20 @@
{
"items": {
"cafe_solo": [
1.0,
1.4
],
"cafe_con_leche": [
1.2,
1.8
],
"pitufo_bacon_queso": [
1.5,
2.8
],
"zumo_naranja": [
2.0,
2.9
]
}
}

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
import time
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.services.menu import build_menu
from app.services.prices import prices_payload, random_price
from app.settings import settings
START_TIME = time.time()
def uptime() -> int:
return int(time.time() - START_TIME)
app = FastAPI(
title="Cafetería API",
description="Devuelve precios y el menú del día.",
version="0.1.0",
)
# Frontend and API will likely run on different ports; allow everything to keep the workshop simple.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {
"status": "ok",
"version": settings.app_version,
"commit": settings.git_commit,
"build": settings.build_number,
"author": settings.commit_author,
"uptime_seconds": uptime()
}
@app.get("/menu")
def menu():
return build_menu()
@app.get("/prices")
def prices():
return prices_payload()
@app.get("/prices/{item}")
def price_for_item(item: str):
return random_price(item)

View File

@@ -0,0 +1,73 @@
import json
import random
from datetime import datetime
from pathlib import Path
from typing import Dict, List
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
ITEMS_PER_SECTION = 3
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
MENU_SOURCE = _load_json("menu_items.json")
def _pick_items(options: List[str], count: int) -> List[str]:
if count >= len(options):
return list(options)
return random.sample(options, count)
def _pick_mains(count: int = ITEMS_PER_SECTION) -> List[str]:
fish_options = MENU_SOURCE["mains"]["fish"]
other_options = MENU_SOURCE["mains"]["others"]
fish_choice = random.choice(fish_options)
remaining_needed = max(count - 1, 0)
pool = [item for item in fish_options if item != fish_choice] + other_options
if remaining_needed > len(pool):
remaining_needed = len(pool)
mains = [fish_choice] + _pick_items(pool, remaining_needed)
random.shuffle(mains)
return mains
def _pick_garnish() -> List[str]:
garnish_options = MENU_SOURCE["mains"]["garnish"]
return _pick_items(garnish_options, 2)
def _build_alternative() -> Dict:
alternative = MENU_SOURCE.get("alternatives", {})
return {
"title": alternative.get("title", "Alternativa"),
"items": alternative.get("items", []),
"price": alternative.get("price"),
"note": alternative.get("note", ""),
}
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
today = datetime.now()
return {
"day": today.strftime("%A").capitalize(),
"starters": _pick_items(MENU_SOURCE["starters"], items_per_section),
"mains": _pick_mains(items_per_section),
"garnish": _pick_garnish(),
"desserts": _pick_items(MENU_SOURCE["desserts"], items_per_section),
"notes": _pick_items(MENU_SOURCE["notes"], 3),
"menu_price": MENU_SOURCE["university_deal"]["current_price"],
"university_deal": MENU_SOURCE["university_deal"],
"espetos_tip": random.choice(MENU_SOURCE["espetos_tips"]),
"alternative": _build_alternative(),
"availability": {
"last_updated": today.isoformat(timespec="seconds"),
},
}

View File

@@ -0,0 +1,34 @@
import json
import random
from datetime import datetime
from pathlib import Path
from typing import Dict
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
PRICE_RANGES = _load_json("price_ranges.json")["items"]
def random_price(item: str) -> Dict:
low, high = PRICE_RANGES.get(item, (1.0, 3.0))
price = min(round(random.uniform(low, high), 2), 3.0)
return {
"item": item,
"price": price,
"currency": "EUR",
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
def prices_payload() -> Dict:
return {
"items": [random_price(item) for item in PRICE_RANGES.keys()],
"disclaimer": "Depende de como pilles al de cafete.",
}

13
backend/app/settings.py Normal file
View File

@@ -0,0 +1,13 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class RuntimeConfig:
app_version: str = os.getenv("APP_VERSION", "dev")
git_commit: str = os.getenv("GIT_COMMIT", "local")
build_number: str = os.getenv("BUILD_NUMBER", "-")
commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
settings = RuntimeConfig()

7
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[tool.ruff]
line-length = 100
target-version = "py311"
select = ["E", "F", "I"]
[tool.pytest.ini_options]
pythonpath = ["."]

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.3
ruff==0.7.1

2
backend/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1

36
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_menu_contains_fish():
response = client.get("/menu")
assert response.status_code == 200
body = response.json()
mains = [main.lower() for main in body["mains"]]
assert any("pescado" in main for main in mains)
assert body["menu_price"] == 5.5
deal = body["university_deal"]
assert deal["old_price"] == 4.5
assert deal["current_price"] == 5.5
def test_prices_random_list():
response = client.get("/prices")
assert response.status_code == 200
body = response.json()
items = body["items"]
assert isinstance(items, list)
assert len(items) >= 1
first = items[0]
assert "item" in first and "price" in first and "currency" in first
assert all(item["price"] <= 3 for item in items)

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.vite
.env
.env.*

8
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.vscode
.idea
dist
.DS_Store
*.log
.svelte-kit
vite.config.ts.timestamp-*

9
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"]
}

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install --no-progress
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS final
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cafetería</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}

3470
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "cafeteria-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check",
"test": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.0",
"@testing-library/user-event": "^14.6.1",
"jsdom": "^27.3.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^4.2.0",
"svelte-check": "^3.8.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
}
}

276
frontend/src/App.svelte Normal file
View File

@@ -0,0 +1,276 @@
<script>
import { onMount } from 'svelte';
import { API_BASE, ENABLE_POLLING } from './config';
import { getCiStatus, getMenu, getPrices } from './services/api';
import { prettify } from './utils/text';
let menu = null;
let prices = [];
let loadingMenu = true;
let loadingPrices = true;
let errorMessage = '';
let ciStatus = null;
let loadingCiStatus = true;
async function fetchMenu() {
loadingMenu = true;
try {
menu = await getMenu();
} catch (error) {
errorMessage = `Hoy no cafete (good ending): ${error.message}`;
} finally {
loadingMenu = false;
}
}
async function fetchPrices() {
loadingPrices = true;
try {
prices = await getPrices();
} catch (error) {
errorMessage = `No pudimos cargar los precios: ${error.message}`;
} finally {
loadingPrices = false;
}
}
async function fetchCiStatus() {
loadingCiStatus = true;
try {
ciStatus = await getCiStatus();
} catch (error) {
errorMessage = `No se pudo obtener el estado del sistema: ${error.message}`;
} finally {
loadingCiStatus = false;
}
}
async function loadInitialData() {
await Promise.all([fetchMenu(), fetchPrices(), fetchCiStatus()]);
}
onMount(() => {
loadInitialData();
if (!ENABLE_POLLING) return;
const pricesInterval = setInterval(fetchPrices, 8000);
const ciInterval = setInterval(fetchCiStatus, 10000);
return () => {
clearInterval(pricesInterval);
clearInterval(ciInterval);
};
});
</script>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Taller CI/CD con Jenkins</p>
<h1>UwU</h1>
<p class="lede">
FastAPI + Svelte para pipelines CI/CD. No se nos ha ocurrido nada mejor para el
taller así que hemos hecho un proyectito basado en una cafetería que para nada nada
está inspirada en la de nuestra querida escuela.
</p>
<div class="actions">
<button on:click={() => fetchMenu()} class="ghost">Refrescar menú</button>
<button on:click={() => fetchPrices()}>Recalcular desayunos</button>
</div>
<p class="meta">
Backend: {API_BASE} · Endpoints: /menu · /prices · /prices/:item · /health
</p>
</div>
<div class="price-hero">
<p class="pill">Convenio universitario</p>
{#if menu}
<div class="price-stack">
<span class="old">{menu.university_deal.old_price.toFixed(2)}</span>
<span class="new">{menu.university_deal.current_price.toFixed(2)}</span>
</div>
<p class="tiny">{menu.university_deal.note}</p>
{:else}
<p class="tiny">Esperando el menú...</p>
{/if}
</div>
</section>
{#if errorMessage}
<div class="banner error">
<p>{errorMessage}</p>
</div>
{/if}
<section class="cards">
<article class="card menu-card">
<div class="card-head">
<div>
<p class="label">Menú del día</p>
</div>
{#if loadingMenu}
<span class="tag">cargando...</span>
{/if}
</div>
{#if menu}
<div class={`menu-layout ${menu?.alternative?.items?.length ? 'with-alternative' : ''}`}>
<div class="menu-primary">
<div class="menu-grid">
<div>
<p class="section-title">Primeros</p>
<ul>
{#each menu.starters as starter}
<li>{starter}</li>
{/each}
</ul>
</div>
<div>
<p class="section-title">Segundos</p>
<ul>
{#each menu.mains as main}
<li class:fish={main.toLowerCase().includes('pescado') || main.toLowerCase().includes('pedro')}>{main}</li>
{/each}
</ul>
</div>
<div>
<p class="section-title">Guarnición</p>
<ul>
{#each menu.garnish as garnish}
<li>{garnish}</li>
{/each}
</ul>
</div>
<div>
<p class="section-title">Postres</p>
<ul>
{#each menu.desserts as dessert}
<li>{dessert}</li>
{/each}
</ul>
</div>
</div>
<div class="notes">
{#each menu.notes as note}
<span>{note}</span>
{/each}
</div>
</div>
{#if menu.alternative?.items?.length}
<aside class="menu-alternative">
<p class="label alt-label">{menu.alternative.title}</p>
<p class="alt-price">
<span class="alt-price-value"
>{menu.alternative.price?.toFixed(2) ?? '5.00'}</span
>
<span class="alt-price-meta">Pues por si no quieres el menú</span>
</p>
<ul class="alt-items">
{#each menu.alternative.items as alternativeItem}
<li>{alternativeItem}</li>
{/each}
</ul>
<span class="chip subtle">Mira si te estás planteando esto, mejor quédate sin comer</span>
</aside>
{/if}
</div>
<div class="deal">
<div>
<p class="caption">Menú convenio</p>
<div class="price-line">
<span class="old">{menu.university_deal.old_price.toFixed(2)}</span>
<span class="new"
>{menu.university_deal.current_price.toFixed(2)}</span
>
</div>
<small class="tiny">{menu.university_deal.note}</small>
</div>
<div class="cta">
<span class="chip">El espeto consejo del día: {menu.espetos_tip}</span>
<span class="chip subtle">
Última actualización {menu.availability.last_updated}
</span>
</div>
</div>
{:else if !loadingMenu}
<p>No pudimos leer el menú.</p>
{/if}
</article>
<article class="card ci-card">
<div class="card-head">
<div>
<p class="label">Estado del sistema</p>
<p class="sub">Información de build y backend</p>
</div>
{#if loadingCiStatus}
<span class="tag">comprobando...</span>
{/if}
</div>
{#if ciStatus}
<div class="ci-grid">
<div class="ci-item">
<p class="caption">API</p>
<p class="highlight">
{ciStatus.status === 'ok' ? '🟢 Operativa' : '🔴 Caída'}
</p>
</div>
<div class="ci-item">
<p class="caption">Build</p>
<p class="highlight">
#{ciStatus.build}
</p>
</div>
<div class="ci-item">
<p class="caption">Commit</p>
<p class="highlight mono">
{ciStatus.commit?.slice(0, 7)}
</p>
</div>
<div class="ci-item">
<p class="caption">Uptime</p>
<p class="highlight">
{ciStatus.uptime_seconds}
</p>
</div>
</div>
<p class="meta">
Autor: {ciStatus.author}
</p>
{:else if !loadingCiStatus}
<p>No se pudo obtener el estado del sistema.</p>
{/if}
</article>
<article class="card">
<div class="card-head">
<div class="label">Desayunos</div>
{#if loadingPrices}
<span class="tag">cargando...</span>
{/if}
</div>
{#if prices.length}
<div class="price-grid">
{#each prices as price}
<div class="price-card">
<p class="item">{prettify(price.item)}</p>
<p class="value">{price.price}</p>
<p class="timestamp">{price.generated_at}</p>
</div>
{/each}
</div>
<p class="meta">
Dependiendo de si vas por la mañana o por la tarde los precios cambian. No sé,
como no ponen los precios al público... :p
</p>
{:else if !loadingPrices}
<p>No hay precios que mostrar.</p>
{/if}
</article>
</section>
</main>

2
frontend/src/config.js Normal file
View File

@@ -0,0 +1,2 @@
export const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
export const ENABLE_POLLING = import.meta.env.MODE !== 'test';

8
frontend/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import './styles/app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app'),
});
export default app;

View File

@@ -0,0 +1,5 @@
<script>
import '../styles/app.css';
</script>
<slot />

View File

@@ -0,0 +1,22 @@
import { API_BASE } from '../config';
async function getJson(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) {
throw new Error('Respuesta no valida del servidor');
}
return response.json();
}
export async function getMenu() {
return getJson('/menu');
}
export async function getPrices() {
const data = await getJson('/prices');
return data.items || [];
}
export async function getCiStatus() {
return getJson('/health');
}

479
frontend/src/styles/app.css Normal file
View File

@@ -0,0 +1,479 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
body {
margin: 0;
font-family: 'Space Grotesk', 'Helvetica Neue', system-ui, sans-serif;
background: radial-gradient(circle at 20% 20%, #0f172a 0, #0f172a 35%, #0a1325);
color: #e2e8f0;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 2.4rem 1.6rem 2.8rem;
}
.hero {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
background: linear-gradient(125deg, #111827, #111b34 45%, #162146);
border: 1px solid rgba(255, 255, 255, 0.06);
color: #f8fafc;
padding: 1.8rem;
border-radius: 22px;
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.35);
margin-bottom: 1.6rem;
position: relative;
overflow: hidden;
}
.hero::after {
content: '';
position: absolute;
right: -40px;
top: -40px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.28), transparent 60%);
filter: blur(2px);
}
.hero-copy h1 {
margin: 0.3rem 0 0.6rem;
font-size: clamp(2.1rem, 4vw, 2.8rem);
}
.hero .lede {
max-width: 720px;
opacity: 0.92;
line-height: 1.5;
color: #e2e8f0;
margin: 0 0 0.6rem;
}
.eyebrow {
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.78rem;
color: #a5b4fc;
margin: 0;
}
.actions {
display: flex;
gap: 0.6rem;
margin: 1rem 0;
flex-wrap: wrap;
}
button {
border: none;
background: #eab308;
color: #0f172a;
padding: 0.75rem 1.1rem;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
transition:
transform 120ms ease,
box-shadow 120ms ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.28);
}
button.ghost {
background: rgba(255, 255, 255, 0.12);
color: #f8fafc;
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: none;
}
button.outline {
background: transparent;
color: #eab308;
border: 1px dashed rgba(234, 179, 8, 0.5);
box-shadow: none;
}
.meta {
color: #cbd5f5;
font-size: 0.92rem;
}
.price-hero {
background: linear-gradient(145deg, #0ea5e9, #22d3ee);
color: #03263b;
border-radius: 16px;
padding: 1rem 1.2rem;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.22),
0 14px 36px rgba(14, 165, 233, 0.35);
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.1rem;
position: relative;
overflow: hidden;
}
.price-hero::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 15% 15%, rgba(255, 255, 255, 0.3), transparent 40%);
pointer-events: none;
}
.price-stack {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin: 0.4rem 0;
}
.old {
text-decoration: line-through;
opacity: 0.7;
font-weight: 600;
}
.new {
font-size: 1.8rem;
font-weight: 800;
}
.pill {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
color: #03263b;
font-weight: 700;
width: fit-content;
}
.availability {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
font-weight: 700;
}
.tiny {
margin: 0;
font-size: 0.85rem;
opacity: 0.9;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.card {
background: #0b1224;
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.label {
font-weight: 800;
color: #f8fafc;
margin: 0;
}
.sub {
margin: 0;
color: #cbd5f5;
font-size: 0.95rem;
}
.tag {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border-radius: 12px;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.menu-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.menu-layout.with-alternative {
grid-template-columns: minmax(0, 1.9fr) minmax(260px, 1.1fr);
align-items: stretch;
}
.menu-primary {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.menu-alternative {
border-radius: 14px;
padding: 1rem 1.1rem;
border: 1px dashed rgba(234, 179, 8, 0.45);
background: linear-gradient(160deg, rgba(234, 179, 8, 0.1), rgba(234, 179, 8, 0.02));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 16px 32px rgba(0, 0, 0, 0.22);
color: #fef9c3;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.alt-label {
color: #fde68a;
}
.alt-price {
margin: 0.15rem 0 0.2rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
}
.alt-price-value {
font-size: 1.5rem;
font-weight: 800;
}
.alt-price-meta {
color: #fef3c7;
font-size: 0.9rem;
}
.alt-items {
list-style: none;
padding-left: 0;
margin: 0.2rem 0 0.1rem;
}
.alt-items li {
position: relative;
padding-left: 1rem;
margin: 0.15rem 0;
}
.alt-items li::before {
content: '•';
position: absolute;
left: 0;
color: #facc15;
opacity: 0.85;
}
.alt-note {
margin: 0.2rem 0;
color: #fefce8;
opacity: 0.9;
}
ul {
padding-left: 1.2rem;
margin: 0.4rem 0 0;
}
li {
margin-bottom: 0.2rem;
}
.section-title {
margin: 0;
font-weight: 700;
color: #e2e8f0;
}
.notes {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.8rem;
}
.notes span {
background: rgba(255, 255, 255, 0.06);
padding: 0.35rem 0.6rem;
border-radius: 10px;
font-size: 0.9rem;
}
.deal {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
align-items: flex-start;
margin-top: 0.8rem;
justify-content: space-between;
}
.price-line {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.fish {
font-weight: 800;
color: #34d399;
}
.chip {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 999px;
background: rgba(56, 189, 248, 0.16);
color: #cffafe;
font-size: 0.9rem;
}
.chip.subtle {
background: rgba(148, 163, 184, 0.16);
color: #cbd5e1;
}
.cta {
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: flex-end;
}
.price-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
}
.price-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 0.7rem 0.9rem;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
}
.item {
margin: 0;
font-weight: 700;
color: #f8fafc;
}
.value {
margin: 0.2rem 0;
font-size: 1.1rem;
font-weight: 800;
color: #38bdf8;
}
.timestamp {
margin: 0;
color: #cbd5f5;
font-size: 0.85rem;
}
.banner {
border-radius: 12px;
padding: 0.75rem 1rem;
margin: 0 0 1rem;
}
.banner.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecdd3;
}
.caption {
margin: 0;
color: #cbd5f5;
font-size: 0.9rem;
}
.highlight {
margin: 0.2rem 0 0.4rem;
font-size: 1.1rem;
font-weight: 700;
color: #f8fafc;
}
.menu-card {
background: linear-gradient(180deg, #0b1224, #0b1224 40%, #0e1530);
}
@media (max-width: 860px) {
.hero {
grid-template-columns: 1fr;
}
.price-hero {
grid-row: 2;
}
}
@media (max-width: 640px) {
main {
padding: 1.5rem 1.1rem 2rem;
}
.hero {
padding: 1.4rem;
}
}
@media (max-width: 960px) {
.menu-layout.with-alternative {
grid-template-columns: 1fr;
}
}
@media (min-width: 980px) {
.menu-card {
grid-column: span 2;
}
}
.ci-card {
background: linear-gradient(180deg, #0f172a, #0b1224);
}
.ci-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.8rem;
}
.ci-item {
padding: 0.7rem 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

View File

@@ -0,0 +1,6 @@
export function prettify(item) {
return item
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View File

@@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
};
export default config;

13
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
test: {
environment: 'jsdom',
globals: true,
},
server: {
port: 5173,
},
});