Init
This commit is contained in:
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