Init
This commit is contained in:
199
Jenkinsfile
vendored
Normal file
199
Jenkinsfile
vendored
Normal 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
66
README.md
Normal 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
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
8
backend/.gitignore
vendored
Normal file
8
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.py[cod]
|
||||||
38
backend/Dockerfile
Normal file
38
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
68
backend/app/data/menu_items.json
Normal file
68
backend/app/data/menu_items.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/app/data/price_ranges.json
Normal file
20
backend/app/data/price_ranges.json
Normal 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
57
backend/app/main.py
Normal 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)
|
||||||
73
backend/app/services/menu.py
Normal file
73
backend/app/services/menu.py
Normal 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"),
|
||||||
|
},
|
||||||
|
}
|
||||||
34
backend/app/services/prices.py
Normal file
34
backend/app/services/prices.py
Normal 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
13
backend/app/settings.py
Normal 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
7
backend/pyproject.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
select = ["E", "F", "I"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.3
|
||||||
|
ruff==0.7.1
|
||||||
2
backend/requirements.txt
Normal file
2
backend/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.1
|
||||||
36
backend/tests/test_api.py
Normal file
36
backend/tests/test_api.py
Normal 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
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
8
frontend/.gitignore
vendored
Normal file
8
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.svelte-kit
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal 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
12
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
11
frontend/nginx.conf
Normal 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
3470
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal 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
276
frontend/src/App.svelte
Normal 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
2
frontend/src/config.js
Normal 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
8
frontend/src/main.js
Normal 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;
|
||||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import '../styles/app.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
22
frontend/src/services/api.js
Normal file
22
frontend/src/services/api.js
Normal 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
479
frontend/src/styles/app.css
Normal 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;
|
||||||
|
}
|
||||||
6
frontend/src/utils/text.js
Normal file
6
frontend/src/utils/text.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function prettify(item) {
|
||||||
|
return item
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
7
frontend/svelte.config.js
Normal file
7
frontend/svelte.config.js
Normal 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
13
frontend/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user