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