Compare commits

..

9 Commits

Author SHA1 Message Date
7a53cc3d66 feat: receive and serve product images pushed from local sites
New POST /api/menu/sync-image (site-authenticated) accepts a product's
image file, stores it under /app/data/product_images keyed by
(site, product_id), and skips the write entirely if the uploaded
content hash matches what's already stored. GET /api/menu/{site_slug}
now injects the cloud-hosted image URL into each product that has no
manual digital_image_url override — replacing the local-only image_url
from the snapshot, which was never reachable from the public internet.

Also fixes the menu-app resolving product image URLs as bare relative
paths instead of prefixing them with the cloud API origin (same class
of bug as the earlier header-image fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:57:00 +03:00
6c7df8d011 fix(menu-app): replace EN|ΕΛ pill with a compact globe icon + dropdown
The two-segment language pill sat over the header image/logo area and
clipped it. Swapped for a small globe icon button tucked closer to the
corner, opening a lightweight dropdown with full language names on tap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:16:18 +03:00
a73f081ca1 fix: QR menu branding fields — display name, header image, empty-to-hide
- menu_display_name is now separate from the site's internal name (was
  incorrectly showing the sysadmin-only site name on the public menu).
- Header image now resolves against the cloud API origin instead of
  the menu-app's own origin, fixing the broken-image icon.
- Tagline/blurb/hours now genuinely hide when cleared: the sysadmin
  save handler was converting empty fields to null, which the PUT
  endpoint's exclude_none silently drops instead of clearing.
- Added a settable blurb (EN/GR) below the tagline, previously
  hardcoded ("Fresh seasonal plates, served with care.").
- Collapsed hours from two fields (EN/GR) to one — opening hours don't
  vary by language.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:44:28 +03:00
f5736b85cb fix(menu-app): crash when tagline/hours fields are null
The API now always sends restaurant.tagline/blurb as {en, gr} objects,
including when a site hasn't set them yet (both null). The old
`?? r.tagline` fallback rendered the whole object as a React child in
that case (React error #31) instead of falling back to a display
string. Fall back to the localized fallback string instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:22:43 +03:00
d87540e08f feat: per-site QR menu mode (order vs view-only) + editable branding
Adds a sysadmin-configurable toggle so sites can disable online ordering
on the public QR menu until it's fully supported, plus editable
tagline/hours and a header image (replacing the hardcoded "Our Menu"
placeholder) — all previously hardcoded frontend strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 09:59:35 +03:00
0cad6a76d3 change the QR Code URl. Just a quick fix 2026-06-01 20:06:46 +03:00
a6f759bf49 fix(menu-app): 6 UI adjustments
1. Font consistency: add Noto Sans as fallback for both display and body
   fonts so Greek characters render in the same visual weight as Latin
2. Category panel gradients: reduce top opacity 0.85 → 0.65 (20% less)
3. Mobile full-width: remove max-w cap on mobile; sm:max-w-[960px] on
   desktop (2× wider than before); shadow only on sm+ breakpoint
4. Smaller Add button (h-9→h-8, text-13→12) and price (22px→18px)
5. More spacing between description and footer hairline (pt-3 mt-3)
6. Square hero image in product detail sheet (aspect-square instead of h-44)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:23:40 +03:00
ffaeab136d feat(menu-app): full redesign to Olive & Thyme design spec
Rebuild the public-facing digital menu from the design handoff bundle.
Matches high-fidelity spec: Bricolage Grotesque + Hanken Grotesk fonts,
cream/green/gold palette, per-category tinted panels, product cards with
gradient art, tag icon badges, bottom-sheet product detail, search overlay,
cart → checkout bottom-sheet flow posting to real API, floating cart button
with bump animation, and restyled order-confirm page.

- New: src/components/primitives.jsx (DishArt, Badge, DietChip, TagIcons,
  Price, DiscountFlag, Stepper, price helpers)
- Rewrite: MenuPage.jsx — all screens in one component tree, API-driven,
  lang toggle (EN/GR) persisted to localStorage, scroll-spy category bar
- Rewrite: OrderConfirm.jsx — design-system styling, brand colors
- Update: App.jsx — remove /order route (cart flow is now a bottom sheet)
- Update: tailwind.config.js — font families, brand tokens, animations
- Update: index.html — Google Fonts for Bricolage Grotesque + Hanken Grotesk
- Delete: CartPage, ProductModal, ProductCard, CategoryNav, CartButton

Cart checkout POSTs to real submitOrder API and redirects to /confirm/:ref
for live order status polling. Restaurant info falls back to hardcoded
placeholder until backend includes it in the fetchMenu response (see
REWORK_REVISIT.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:55:52 +03:00
17fb3a2589 fix(sysadmin): use site_id UUID string instead of integer PK throughout manager endpoints
The sysadmin panel URL uses site.site_id (UUID string) not site.id (int PK).
All manager account endpoints were querying/expecting the integer PK — none matched.

- GET /by-site/{site_id}: param type str, filter by Site.site_id
- POST /register: site_ids type list[str], query Site.site_id.in_()
- DELETE /site-access: site_id type str, query Site.site_id
- schemas/manager.py: ManagerRegisterRequest.site_ids list[int] → list[str]
- SiteDetailPage.jsx: remove Number() casts on siteId in add/remove calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:45:10 +03:00
23 changed files with 4815 additions and 559 deletions

View File

@@ -1,6 +1,8 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from config import settings
from database import engine, Base
@@ -11,6 +13,7 @@ import models.menu_snapshot # noqa: F401
import models.online_order # noqa: F401
import models.manager_account # noqa: F401
import models.stats_snapshot # noqa: F401
import models.product_image # noqa: F401
from routers import auth, sites, heartbeat
from routers import menu as menu_router
@@ -39,6 +42,17 @@ def _run_migrations():
migrations = [
# Per-site order counter for public_ref generation (e.g. "ORD-0042")
"ALTER TABLE sites ADD COLUMN order_counter INTEGER NOT NULL DEFAULT 0",
# QR menu branding/config
"ALTER TABLE sites ADD COLUMN menu_mode VARCHAR NOT NULL DEFAULT 'order'",
"ALTER TABLE sites ADD COLUMN menu_tagline_en VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_tagline_gr VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_hours_en VARCHAR", # superseded by menu_hours, kept for history
"ALTER TABLE sites ADD COLUMN menu_hours_gr VARCHAR", # superseded by menu_hours, kept for history
"ALTER TABLE sites ADD COLUMN menu_header_image_url VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_display_name VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_blurb_en VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_blurb_gr VARCHAR",
"ALTER TABLE sites ADD COLUMN menu_hours VARCHAR",
]
for sql in migrations:
try:
@@ -74,6 +88,12 @@ app.include_router(orders_router.router, prefix="/api/orders", tags=
app.include_router(manager_auth_router.router, prefix="/api/manager", tags=["manager"])
app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags=["remote"])
os.makedirs("/app/data/site_headers", exist_ok=True)
app.mount("/static/site_headers", StaticFiles(directory="/app/data/site_headers"), name="site_headers")
os.makedirs("/app/data/product_images", exist_ok=True)
app.mount("/static/product_images", StaticFiles(directory="/app/data/product_images"), name="product_images")
@app.get("/health")
def health():

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from database import Base
class ProductImage(Base):
"""Cloud-hosted copy of a local product's image, pushed by local_backend
during menu sync. Used as a fallback on the public QR menu when the
product has no digital_image_url override set."""
__tablename__ = "product_images"
id = Column(Integer, primary_key=True, index=True)
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, index=True)
product_id = Column(Integer, nullable=False)
image_url = Column(String, nullable=False)
image_hash = Column(String, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("site_id", "product_id", name="uq_product_images_site_product"),)

View File

@@ -23,3 +23,13 @@ class Site(Base):
waiter_domain = Column(String, nullable=True)
# Monotonically incrementing counter used to generate public_ref for online orders
order_counter = Column(Integer, default=0, nullable=False)
# QR menu branding/config
menu_mode = Column(String, nullable=False, default="order") # "order" | "view_only"
menu_display_name = Column(String, nullable=True)
menu_tagline_en = Column(String, nullable=True)
menu_tagline_gr = Column(String, nullable=True)
menu_blurb_en = Column(String, nullable=True)
menu_blurb_gr = Column(String, nullable=True)
menu_hours = Column(String, nullable=True)
menu_header_image_url = Column(String, nullable=True)

View File

@@ -59,7 +59,7 @@ def register_manager(
_admin=Depends(get_current_admin),
):
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
sites = db.query(Site).filter(Site.site_id.in_(body.site_ids)).all()
if existing:
# Email already exists — just add the new site access links, don't recreate the account
@@ -114,11 +114,11 @@ class ManagerBySiteOut(BaseModel):
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
def get_managers_by_site(
site_id: int,
site_id: str,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
site = db.query(Site).filter(Site.id == site_id).first()
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site.manager_accounts
@@ -128,7 +128,7 @@ def get_managers_by_site(
class SiteAccessRemoveRequest(BaseModel):
manager_id: int
site_id: int
site_id: str # site_id UUID string, not integer PK
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
@@ -141,7 +141,7 @@ def remove_manager_site_access(
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
site = db.query(Site).filter(Site.id == body.site_id).first()
site = db.query(Site).filter(Site.site_id == body.site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")

View File

@@ -1,15 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, Header, status
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Header, UploadFile, File, Form, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from database import get_db
from models.site import Site
from models.menu_snapshot import MenuSnapshot
from models.product_image import ProductImage
from schemas.menu import MenuSyncRequest, MenuSyncResponse
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
PRODUCT_IMAGE_DIR = "/app/data/product_images"
def _require_site(
x_site_id: str = Header(..., alias="X-Site-ID"),
@@ -36,7 +41,30 @@ def get_menu(site_slug: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="No menu published yet")
import json
return json.loads(snapshot.snapshot_json)
data = json.loads(snapshot.snapshot_json)
data["menu_mode"] = site.menu_mode
data["restaurant"] = {
"name": site.menu_display_name,
"tagline": {"en": site.menu_tagline_en, "gr": site.menu_tagline_gr},
"blurb": {"en": site.menu_blurb_en, "gr": site.menu_blurb_gr},
"hours": site.menu_hours,
"headerImageUrl": site.menu_header_image_url,
}
cloud_images = {
img.product_id: img.image_url
for img in db.query(ProductImage).filter(ProductImage.site_id == site.id).all()
}
for cat in data.get("categories", []):
for product in cat.get("products", []):
# image_url as pushed by local_backend points at the restaurant's own
# LAN/local server and isn't reachable from the internet — always
# replace it with the cloud-hosted copy (or None) unless a manual
# digital_image_url override is set.
if not product.get("digital_image_url"):
product["image_url"] = cloud_images.get(product.get("id"))
return data
# ── Internal (site API key) ───────────────────────────────────────────────────
@@ -52,3 +80,54 @@ def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Se
db.add(snapshot)
db.commit()
return MenuSyncResponse(ok=True)
@router.post("/sync-image")
async def sync_product_image(
product_id: int = Form(...),
file: UploadFile = File(...),
site: Site = Depends(_require_site),
db: Session = Depends(get_db),
):
"""Upload/replace the cloud-hosted copy of a product's image. Called by
local_backend during menu sync for products whose image changed."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
contents = await file.read()
import hashlib
image_hash = hashlib.sha256(contents).hexdigest()
record = (
db.query(ProductImage)
.filter(ProductImage.site_id == site.id, ProductImage.product_id == product_id)
.first()
)
os.makedirs(PRODUCT_IMAGE_DIR, exist_ok=True)
if record and record.image_hash == image_hash:
return {"ok": True, "image_url": record.image_url, "unchanged": True}
if record:
old_path = os.path.join(PRODUCT_IMAGE_DIR, os.path.basename(record.image_url))
if os.path.exists(old_path):
os.remove(old_path)
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "jpg"
filename = f"{site.id}_{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
filepath = os.path.join(PRODUCT_IMAGE_DIR, filename)
with open(filepath, "wb") as f:
f.write(contents)
image_url = f"/static/product_images/{filename}"
if record:
record.image_url = image_url
record.image_hash = image_hash
else:
record = ProductImage(site_id=site.id, product_id=product_id, image_url=image_url, image_hash=image_hash)
db.add(record)
db.commit()
return {"ok": True, "image_url": image_url, "unchanged": False}

View File

@@ -1,7 +1,8 @@
import os
import secrets
import uuid
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from auth_utils import get_current_admin
@@ -12,6 +13,8 @@ from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRe
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
HEADER_IMAGE_DIR = "/app/data/site_headers"
@router.get("/", response_model=list[SiteOut])
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
@@ -50,6 +53,8 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if body.menu_mode is not None and body.menu_mode not in ("order", "view_only"):
raise HTTPException(status_code=400, detail="menu_mode must be 'order' or 'view_only'")
for field, value in body.model_dump(exclude_none=True).items():
setattr(site, field, value)
db.commit()
@@ -57,6 +62,51 @@ def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _
return site
@router.post("/{site_id}/header-image", response_model=SiteOut)
async def upload_header_image(site_id: str, file: UploadFile = File(...), db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
os.makedirs(HEADER_IMAGE_DIR, exist_ok=True)
if site.menu_header_image_url:
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
if os.path.exists(old_path):
os.remove(old_path)
filename = f"{site.site_id}_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(HEADER_IMAGE_DIR, filename)
contents = await file.read()
with open(filepath, "wb") as f:
f.write(contents)
site.menu_header_image_url = f"/static/site_headers/{filename}"
db.commit()
db.refresh(site)
return site
@router.delete("/{site_id}/header-image", response_model=SiteOut)
def delete_header_image(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site.menu_header_image_url:
old_path = os.path.join(HEADER_IMAGE_DIR, os.path.basename(site.menu_header_image_url))
if os.path.exists(old_path):
os.remove(old_path)
site.menu_header_image_url = None
db.commit()
db.refresh(site)
return site
@router.post("/{site_id}/lock", response_model=SiteOut)
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
site = db.query(Site).filter(Site.site_id == site_id).first()

View File

@@ -8,7 +8,7 @@ class ManagerRegisterRequest(BaseModel):
email: str
password: str
full_name: Optional[str] = None
site_ids: list[int] = []
site_ids: list[str] = [] # site_id UUID strings, not integer PKs
class ManagerLoginRequest(BaseModel):

View File

@@ -15,6 +15,13 @@ class SiteUpdate(BaseModel):
contact_email: str | None = None
license_expires_at: datetime | None = None
waiter_domain: str | None = None
menu_mode: str | None = None
menu_display_name: str | None = None
menu_tagline_en: str | None = None
menu_tagline_gr: str | None = None
menu_blurb_en: str | None = None
menu_blurb_gr: str | None = None
menu_hours: str | None = None
class SiteOut(BaseModel):
@@ -32,6 +39,14 @@ class SiteOut(BaseModel):
last_seen_ip: str | None
last_seen_local_ip: str | None
waiter_domain: str | None
menu_mode: str
menu_display_name: str | None
menu_tagline_en: str | None
menu_tagline_gr: str | None
menu_blurb_en: str | None
menu_blurb_gr: str | None
menu_hours: str | None
menu_header_image_url: str | None
model_config = {"from_attributes": True}

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="el">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xenia Menu</title>
<title>Menu</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Hanken+Grotesk:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

3087
connect_frontend/menu-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import MenuPage from './pages/MenuPage'
import CartPage from './pages/CartPage'
import OrderConfirm from './pages/OrderConfirm'
export default function App() {
return (
<Routes>
<Route path="/:siteSlug" element={<MenuPage />} />
<Route path="/:siteSlug/order" element={<CartPage />} />
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,8 +1,8 @@
import axios from 'axios'
const BASE = import.meta.env.VITE_CLOUD_URL || ''
export const CLOUD_URL = import.meta.env.VITE_CLOUD_URL || ''
const api = axios.create({ baseURL: BASE })
const api = axios.create({ baseURL: CLOUD_URL })
export async function fetchMenu(siteSlug) {
const { data } = await api.get(`/api/menu/${siteSlug}`)

View File

@@ -1,25 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { ShoppingCart } from 'lucide-react'
export default function CartButton({ cart, siteSlug }) {
const navigate = useNavigate()
const count = cart.reduce((s, i) => s + i.quantity, 0)
const total = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
if (!count) return null
return (
<div className="fixed bottom-6 left-0 right-0 flex justify-center px-4 z-30">
<button
onClick={() => navigate(`/${siteSlug}/order`, { state: { cart } })}
className="bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl px-6 py-3.5 shadow-lg flex items-center gap-3 w-full max-w-sm transition-colors"
>
<span className="bg-emerald-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center">
{count}
</span>
<span className="flex-1 font-semibold text-left">View order</span>
<span className="font-bold">{total.toFixed(2)}</span>
</button>
</div>
)
}

View File

@@ -1,19 +0,0 @@
export default function CategoryNav({ categories, active, onSelect }) {
return (
<div className="flex gap-1 overflow-x-auto px-4 pb-2 pt-1 scrollbar-hide">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => onSelect(cat.id)}
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
active === cat.id
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{cat.name}
</button>
))}
</div>
)
}

View File

@@ -1,55 +0,0 @@
export default function ProductCard({ product, onSelect }) {
const unavailable = !product.digital_available
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
return (
<button
onClick={unavailable ? undefined : onSelect}
className={`w-full bg-white rounded-2xl shadow-sm p-4 flex gap-4 text-left transition-shadow ${
unavailable ? 'opacity-60 cursor-default' : 'hover:shadow-md active:scale-[0.99]'
}`}
>
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-slate-800 leading-tight">
{product.digital_name || product.name}
</h3>
{unavailable && (
<span className="flex-shrink-0 text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full">
Out of stock
</span>
)}
</div>
{product.digital_description && (
<p className="text-xs text-slate-500 line-clamp-2">{product.digital_description}</p>
)}
<div className="flex items-baseline gap-2 pt-1">
<span className="font-bold text-emerald-600">{displayPrice.toFixed(2)}</span>
{hasDiscount && (
<>
<span className="text-xs text-slate-400 line-through">{basePrice.toFixed(2)}</span>
<span className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
</>
)}
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,209 @@
import {
Leaf, Sprout, Wheat, Flame, Star, ChefHat,
Minus, Plus,
} from 'lucide-react'
// ── Money helpers ────────────────────────────────────────────────────────────
export function eur(n) {
return '€' + Number(n).toFixed(2)
}
export function discountedPrice(product) {
const base = product.digital_price ?? product.base_price ?? product.price ?? 0
const pct = product.digital_discount ?? product.discountPct ?? 0
if (!pct) return base
return Math.round(base * (1 - pct / 100) * 100) / 100
}
export function basePrice(product) {
return product.digital_price ?? product.base_price ?? product.price ?? 0
}
export function hasDiscount(product) {
const pct = product.digital_discount ?? product.discountPct ?? 0
return pct > 0
}
export function discountPct(product) {
return product.digital_discount ?? product.discountPct ?? 0
}
// ── Placeholder gradient art ─────────────────────────────────────────────────
export function DishArt({ product, category, size = 'card' }) {
const hue = category?.hue ?? 40
const GlyphIcon = category?.GlyphIcon ?? null
const id = product?.id ?? 'x'
let seed = 0
for (let i = 0; i < id.length; i++) seed += id.charCodeAt(i)
const lift = (seed % 5) - 2
const c1 = `hsl(${hue} 34% ${90 + lift}%)`
const c2 = `hsl(${hue} 30% ${80 + lift}%)`
const glyphColor = `hsl(${hue} 32% 42%)`
const firstName = product?.digital_name || product?.name || '?'
const letter = typeof firstName === 'object' ? (firstName.en?.[0] ?? '?') : (firstName[0] ?? '?')
const sizeClass =
size === 'hero'
? 'self-stretch min-h-[100px] w-[100px]'
: size === 'sm'
? 'h-16 w-16'
: 'h-[92px] w-[92px]'
const iconClass = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'
return (
<div
className={`relative flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-[13px]`}
style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}
>
<span
className="absolute -right-2 -top-3 font-display text-[56px] leading-none opacity-[0.14] select-none"
style={{ color: glyphColor }}
>
{letter}
</span>
{GlyphIcon && (
<GlyphIcon
className={iconClass}
style={{ color: glyphColor, opacity: 0.62 }}
strokeWidth={1.4}
/>
)}
</div>
)
}
// ── Badge (Popular / Chef's pick) ────────────────────────────────────────────
export function Badge({ kind, t }) {
if (kind === 'popular') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#f4ecd8] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#a9842f] ring-1 ring-inset ring-[#e4d4a8]">
<Flame className="h-3 w-3" strokeWidth={2.2} />
{t?.popular ?? 'Popular'}
</span>
)
}
if (kind === 'chefs') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[#2d3b2d] px-2 py-[3px] text-[10px] font-semibold uppercase tracking-[0.08em] text-[#f0e9d6]">
<ChefHat className="h-3 w-3" strokeWidth={2} />
{t?.chefs ?? "Chef's pick"}
</span>
)
}
return null
}
// ── Dietary chips (with text labels) ────────────────────────────────────────
const DIET_STYLE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#c9e2cb' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#d8e2bd' },
'gluten-free': { Icon: Wheat, fg: '#a9842f', bg: '#f5edd8', ring: '#e6d6a6' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f7e6dc', ring: '#eccab3' },
}
export function DietChip({ tag, t }) {
const s = DIET_STYLE[tag]
if (!s) return null
const { Icon } = s
const label = t?.dietary?.[tag] ?? tag
return (
<span
className="inline-flex items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2} />
{label}
</span>
)
}
// ── Compact tag icon badges (card title row) ─────────────────────────────────
const TAG_BADGE = {
vegan: { Icon: Leaf, fg: '#3f7d4e', bg: '#e7f1e7', ring: '#bcdcc0' },
vegetarian: { Icon: Sprout, fg: '#5d7a37', bg: '#eef2e0', ring: '#cfe0b0' },
'gluten-free': { Icon: Wheat, fg: '#9a7726', bg: '#f6edd6', ring: '#e6d49e' },
spicy: { Icon: Flame, fg: '#c2602f', bg: '#f8e6da', ring: '#eec4ac' },
}
const PRIORITY_BADGE = {
popular: { Icon: Star, fg: '#a9842f', bg: '#f6edd6', ring: '#e6d49e' },
chefs: { Icon: ChefHat, fg: '#f0e9d6', bg: '#2d3b2d', ring: '#2d3b2d' },
}
export function TagIcons({ product }) {
const items = []
const badge = product.badge ?? product.digital_badge
if (badge && PRIORITY_BADGE[badge]) items.push(PRIORITY_BADGE[badge])
const tags = product.tags ?? product.digital_tags ?? []
tags.forEach(tag => { if (TAG_BADGE[tag]) items.push(TAG_BADGE[tag]) })
const shown = items.slice(0, 3)
if (!shown.length) return null
return (
<div className="flex shrink-0 items-center gap-1 pt-[3px]">
{shown.map((s, i) => {
const { Icon } = s
return (
<span
key={i}
className="flex h-[19px] w-[19px] items-center justify-center rounded-full ring-1 ring-inset"
style={{ color: s.fg, background: s.bg, borderColor: s.ring }}
>
<Icon className="h-[11px] w-[11px]" strokeWidth={2.2} />
</span>
)
})}
</div>
)
}
// ── Price display ─────────────────────────────────────────────────────────────
export function Price({ product, large }) {
const base = basePrice(product)
const now = discountedPrice(product)
const discounted = hasDiscount(product)
return (
<div className="flex items-baseline gap-1.5">
{discounted && (
<span className="font-display text-[13px] text-[#b3aa97] line-through">{eur(base)}</span>
)}
<span
className={`font-display ${large ? 'text-[18px]' : 'text-[16px]'} font-semibold ${discounted ? 'text-[#c2602f]' : 'text-[#2d3b2d]'}`}
>
{eur(now)}
</span>
</div>
)
}
// ── Discount flag ─────────────────────────────────────────────────────────────
export function DiscountFlag({ product, t }) {
const pct = discountPct(product)
if (!pct) return null
return (
<span className="inline-flex items-center rounded-md bg-[#c2602f] px-1.5 py-[2px] font-sans text-[10px] font-bold tracking-[0.05em] text-white">
{pct}% {t?.off ?? 'OFF'}
</span>
)
}
// ── Quantity stepper ─────────────────────────────────────────────────────────
export function Stepper({ qty, onInc, onDec }) {
return (
<div className="flex items-center gap-3 rounded-full bg-[#f3efe5] p-1 ring-1 ring-inset ring-[#e8e1d1]">
<button
onClick={onDec}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#2d3b2d] shadow-sm ring-1 ring-[#e8e1d1] transition active:scale-90"
>
<Minus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
<span className="min-w-[16px] text-center font-display text-[16px] font-semibold tabular-nums text-[#2d3b2d]">
{qty}
</span>
<button
onClick={onInc}
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#2d3b2d] text-white shadow-sm transition active:scale-90"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
</div>
)
}

View File

@@ -3,4 +3,16 @@
@tailwind utilities;
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Geist', system-ui, sans-serif; background: #f8fafc; }
body {
margin: 0;
font-family: 'Hanken Grotesk', system-ui, sans-serif;
background: radial-gradient(ellipse at top, #f3eedf 0%, #ece4d2 50%, #e6ddc8 100%);
min-height: 100dvh;
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.font-display { font-family: 'Bricolage Grotesque', serif; }
}

View File

@@ -1,178 +0,0 @@
import { useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ArrowLeft, Truck, UtensilsCrossed } from 'lucide-react'
import { submitOrder } from '../api'
import toast from 'react-hot-toast'
export default function CartPage() {
const { siteSlug } = useParams()
const navigate = useNavigate()
const { state } = useLocation()
const cart = state?.cart || []
const [orderType, setOrderType] = useState('dine_in')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [address, setAddress] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const subtotal = cart.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const deliveryFee = orderType === 'delivery' ? 2.0 : 0.0
const total = subtotal + deliveryFee
if (!cart.length) {
navigate(`/${siteSlug}`, { replace: true })
return null
}
async function handleSubmit(e) {
e.preventDefault()
if (!name.trim()) { toast.error('Please enter your name'); return }
if (orderType === 'delivery' && !address.trim()) {
toast.error('Please enter your delivery address')
return
}
setSubmitting(true)
try {
const result = await submitOrder(siteSlug, {
order_type: orderType,
customer_name: name.trim(),
customer_phone: phone.trim() || null,
customer_address: orderType === 'delivery' ? address.trim() : null,
customer_notes: notes.trim() || null,
items: cart.map(i => ({
product_id: i.product_id,
name: i.name,
quantity: i.quantity,
unit_price: i.unit_price,
options: i.options || [],
})),
subtotal,
delivery_fee: deliveryFee,
total,
})
navigate(`/${siteSlug}/confirm/${result.public_ref}`, { replace: true })
} catch (err) {
toast.error(err.response?.data?.detail || 'Failed to place order. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<div className="min-h-screen bg-slate-50 pb-10">
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 shadow-sm">
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-500 hover:text-slate-800">
<ArrowLeft size={22} />
</button>
<h1 className="text-lg font-bold text-slate-800">Your Order</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="max-w-lg mx-auto px-4 py-5 space-y-5">
{/* Order type */}
<div className="bg-white rounded-2xl p-4 space-y-3 shadow-sm">
<p className="text-sm font-semibold text-slate-700">Order type</p>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'dine_in', label: 'Dine In', Icon: UtensilsCrossed },
{ id: 'delivery', label: 'Delivery', Icon: Truck },
].map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setOrderType(id)}
className={`flex flex-col items-center gap-2 py-4 rounded-xl border-2 transition-colors ${
orderType === id
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
<Icon size={22} />
<span className="text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
{/* Cart summary */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<p className="text-sm font-semibold text-slate-700 mb-3">Items</p>
{cart.map((item, idx) => (
<div key={idx} className="flex justify-between text-sm">
<span className="text-slate-700">{item.quantity}× {item.name}</span>
<span className="text-slate-600 font-medium">{(item.unit_price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="border-t border-slate-100 pt-2 mt-2 space-y-1">
<div className="flex justify-between text-sm text-slate-500">
<span>Subtotal</span><span>{subtotal.toFixed(2)}</span>
</div>
{deliveryFee > 0 && (
<div className="flex justify-between text-sm text-slate-500">
<span>Delivery fee</span><span>{deliveryFee.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-800">
<span>Total</span><span>{total.toFixed(2)}</span>
</div>
</div>
</div>
{/* Customer details */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-sm font-semibold text-slate-700">Your details</p>
{[
{ label: 'Name *', value: name, set: setName, type: 'text', placeholder: 'Full name' },
{ label: 'Phone', value: phone, set: setPhone, type: 'tel', placeholder: 'Optional' },
].map(({ label, value, set, type, placeholder }) => (
<div key={label}>
<label className="text-xs text-slate-500 font-medium">{label}</label>
<input
type={type}
value={value}
onChange={e => set(e.target.value)}
placeholder={placeholder}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100"
/>
</div>
))}
{orderType === 'delivery' && (
<div>
<label className="text-xs text-slate-500 font-medium">Delivery address *</label>
<textarea
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Street, number, city"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
)}
<div>
<label className="text-xs text-slate-500 font-medium">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Allergies, special requests…"
rows={2}
className="mt-1 w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-emerald-400 focus:ring-1 focus:ring-emerald-100 resize-none"
/>
</div>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-60 text-white py-4 rounded-xl font-bold text-base transition-colors"
>
{submitting ? 'Placing order…' : `Place order · €${total.toFixed(2)}`}
</button>
</form>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, Clock, XCircle, ChefHat, Truck, Package } from 'lucide-react'
import { Check, Clock, X, ChefHat, Truck, Package, Leaf } from 'lucide-react'
import { fetchOrderStatus } from '../api'
const STATUS_CONFIG = {
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' },
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: '#c9a24b', bg: '#f6edd6' },
accepted: { label: 'Order accepted!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
rejected: { label: 'Order declined', Icon: X, color: '#c2602f', bg: '#f7e6dc' },
preparing: { label: 'Being prepared', Icon: ChefHat, color: '#2d3b2d', bg: '#e7ede7' },
ready: { label: 'Ready for pickup!', Icon: Package, color: '#3f7d4e', bg: '#e7f1e7' },
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: '#2d3b2d', bg: '#e7ede7' },
delivered: { label: 'Delivered!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
}
const TERMINAL = new Set(['rejected', 'delivered'])
@@ -42,48 +42,73 @@ export default function OrderConfirm() {
return () => clearTimeout(timer)
}, [ref])
const cfg = STATUS_CONFIG[orderStatus] || STATUS_CONFIG['pending_acceptance']
const cfg = STATUS_CONFIG[orderStatus] ?? STATUS_CONFIG.pending_acceptance
const { label, Icon, color, bg } = cfg
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
<div className="relative mx-auto min-h-dvh max-w-[480px] bg-[#faf7f0] shadow-[0_0_60px_-20px_rgba(45,42,31,0.3)] flex flex-col items-center justify-center px-6 py-12">
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
<Icon className={color} size={40} />
{/* Ornament top */}
<div className="mb-8 flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3.5 w-3.5 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
{/* Status icon */}
<div
className="flex h-20 w-20 items-center justify-center rounded-full"
style={{ background: bg }}
>
<Icon size={38} style={{ color }} strokeWidth={2.2} />
</div>
{/* Ref number */}
<p className="mt-5 font-sans text-[11px] font-semibold uppercase tracking-[0.22em] text-[#b3aa90]">
{ref}
</p>
{/* Status label */}
<h1 className="mt-2 font-display text-[28px] font-semibold leading-tight text-center text-[#2d3b2d]">
{error ? 'Could not load status' : label}
</h1>
{/* Rejection reason */}
{rejectionReason && (
<p className="mt-2 text-[14px] text-center text-[#7d7660]">
Reason: {rejectionReason}
</p>
)}
{/* Subtext */}
{orderStatus === 'delivered' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
Thank you for your order! Enjoy your meal.
</p>
)}
{orderStatus === 'rejected' && (
<p className="mt-3 max-w-[280px] text-center text-[14px] leading-relaxed text-[#7d7660]">
We're sorry we couldn't take your order this time. Please try again or speak to staff.
</p>
)}
{!TERMINAL.has(orderStatus) && !error && (
<p className="mt-3 text-[13px] text-[#9a917a]">
We'll update this page automatically. Keep it open.
</p>
)}
{/* Spinner */}
{!TERMINAL.has(orderStatus) && !error && (
<div className="mt-6 flex justify-center">
<div className="h-5 w-5 rounded-full border-2 border-[#2d3b2d] border-t-transparent animate-spin" />
</div>
)}
<div className="space-y-1">
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
<h1 className="text-2xl font-bold text-slate-800">
{error ? 'Could not load status' : label}
</h1>
{rejectionReason && (
<p className="text-sm text-slate-500 mt-1">Reason: {rejectionReason}</p>
)}
</div>
{!TERMINAL.has(orderStatus) && !error && (
<p className="text-xs text-slate-400">
We'll update this page automatically. Keep it open.
</p>
)}
{orderStatus === 'rejected' && (
<p className="text-sm text-slate-500">
We're sorry we couldn't take your order this time. Please try again or visit us in person.
</p>
)}
{orderStatus === 'delivered' && (
<p className="text-sm text-slate-500">Thank you for your order! Enjoy your meal.</p>
)}
{!TERMINAL.has(orderStatus) && !error && (
<div className="flex justify-center">
<div className="w-5 h-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
</div>
)}
{/* Ornament bottom */}
<div className="mt-10 flex w-32 items-center gap-2">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
</div>
</div>
)

View File

@@ -1,136 +0,0 @@
import { useState } from 'react'
import { X, Plus, Minus } from 'lucide-react'
export default function ProductModal({ product, onClose, onAdd }) {
const [quantity, setQuantity] = useState(1)
const [selectedOptions, setSelectedOptions] = useState([])
const basePrice = product.digital_price ?? product.base_price
const hasDiscount = product.digital_discount > 0 && !product.digital_price
const displayPrice = hasDiscount
? basePrice * (1 - product.digital_discount / 100)
: basePrice
function toggleOption(opt) {
setSelectedOptions(prev =>
prev.find(o => o.id === opt.id)
? prev.filter(o => o.id !== opt.id)
: [...prev, opt]
)
}
const optionsTotal = selectedOptions.reduce((s, o) => s + (o.price || 0), 0)
const lineTotal = (displayPrice + optionsTotal) * quantity
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-full max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[90vh] overflow-y-auto">
{/* Image */}
{(product.digital_image_url || product.image_url) && (
<img
src={product.digital_image_url || product.image_url}
alt={product.digital_name || product.name}
className="w-full h-48 object-cover rounded-t-2xl sm:rounded-t-2xl"
/>
)}
<button
onClick={onClose}
className="absolute top-3 right-3 bg-white/90 rounded-full p-1.5 shadow"
>
<X size={18} />
</button>
<div className="p-5 space-y-4">
<div>
<h2 className="text-xl font-bold text-slate-800">
{product.digital_name || product.name}
</h2>
{product.digital_description && (
<p className="text-sm text-slate-500 mt-1">{product.digital_description}</p>
)}
</div>
{/* Price */}
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-emerald-600">
{displayPrice.toFixed(2)}
</span>
{hasDiscount && (
<span className="text-sm text-slate-400 line-through">
{basePrice.toFixed(2)}
</span>
)}
{hasDiscount && (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full font-semibold">
-{product.digital_discount}%
</span>
)}
</div>
{/* Quick options */}
{product.quick_options?.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-semibold text-slate-700">Options</p>
<div className="flex flex-wrap gap-2">
{product.quick_options.map(opt => {
const active = selectedOptions.find(o => o.id === opt.id)
return (
<button
key={opt.id}
onClick={() => toggleOption(opt)}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-emerald-500 text-white border-emerald-500'
: 'bg-white text-slate-700 border-slate-200 hover:border-emerald-300'
}`}
>
{opt.name}{opt.price > 0 ? ` +€${opt.price.toFixed(2)}` : ''}
</button>
)
})}
</div>
</div>
)}
{/* Quantity */}
<div className="flex items-center gap-4">
<span className="text-sm font-semibold text-slate-700">Quantity</span>
<div className="flex items-center gap-3">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Minus size={16} />
</button>
<span className="text-lg font-bold w-6 text-center">{quantity}</span>
<button
onClick={() => setQuantity(q => q + 1)}
className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center hover:bg-slate-200"
>
<Plus size={16} />
</button>
</div>
</div>
{/* Add to cart */}
{product.digital_available ? (
<button
onClick={() => onAdd(product, quantity, selectedOptions)}
className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3.5 rounded-xl font-semibold flex items-center justify-between px-5 transition-colors"
>
<span>Add to order</span>
<span>{lineTotal.toFixed(2)}</span>
</button>
) : (
<div className="w-full bg-slate-100 text-slate-400 py-3.5 rounded-xl font-semibold text-center">
Currently unavailable
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -3,7 +3,49 @@ export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
fontFamily: {
display: ['Bricolage Grotesque', 'Noto Sans', 'system-ui', 'sans-serif'],
sans: ['Hanken Grotesk', 'Noto Sans', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
dark: '#2d3b2d',
hover: '#26331f',
},
cream: '#faf7f0',
card: '#fcfbf7',
gold: '#c9a24b',
terracotta: '#c2602f',
sage: '#9caf88',
success: '#3f7d4e',
},
borderRadius: {
card: '20px',
section: '22px',
sheet: '24px',
},
boxShadow: {
card: '0 5px 16px -10px rgba(45,42,31,0.45)',
'card-hover': '0 10px 24px -12px rgba(45,42,31,0.5)',
cart: '0 12px 28px -8px rgba(45,59,45,0.55)',
},
keyframes: {
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
slideup: {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
pop: {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.06)' },
'100%': { transform: 'scale(1)' },
},
},
animation: {
fade: 'fade 0.2s ease',
slideup: 'slideup 0.28s cubic-bezier(0.22,1,0.36,1)',
pop: 'pop 0.32s ease',
},
},
},
plugins: [],

View File

@@ -27,12 +27,23 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager' | 'menu_settings'
const [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false)
// Menu settings form state
const [menuMode, setMenuMode] = useState('order')
const [displayName, setDisplayName] = useState('')
const [taglineEn, setTaglineEn] = useState('')
const [taglineGr, setTaglineGr] = useState('')
const [blurbEn, setBlurbEn] = useState('')
const [blurbGr, setBlurbGr] = useState('')
const [hours, setHours] = useState('')
const [headerImageFile, setHeaderImageFile] = useState(null)
const [uploadingHeaderImage, setUploadingHeaderImage] = useState(false)
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
@@ -75,7 +86,7 @@ export default function SiteDetailPage() {
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
setAddingMgr(true)
try {
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), Number(siteId))
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), siteId)
toast.success('Manager added')
setModal(null)
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
@@ -90,7 +101,7 @@ export default function SiteDetailPage() {
async function doRemoveManager(managerId) {
setRemovingMgrId(managerId)
try {
await removeManagerSiteAccess(managerId, Number(siteId))
await removeManagerSiteAccess(managerId, siteId)
toast.success('Access removed')
setManagers(prev => prev.filter(m => m.id !== managerId))
} catch (e) {
@@ -163,6 +174,58 @@ export default function SiteDetailPage() {
}
}
async function doSaveMenuSettings() {
setActing(true)
try {
const { data } = await client.put(`/api/sites/${siteId}`, {
menu_mode: menuMode,
menu_display_name: displayName.trim(),
menu_tagline_en: taglineEn.trim(),
menu_tagline_gr: taglineGr.trim(),
menu_blurb_en: blurbEn.trim(),
menu_blurb_gr: blurbGr.trim(),
menu_hours: hours.trim(),
})
setSite(data)
setModal(null)
toast.success('Menu settings updated')
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to update menu settings')
} finally {
setActing(false)
}
}
async function doUploadHeaderImage() {
if (!headerImageFile) return
setUploadingHeaderImage(true)
try {
const form = new FormData()
form.append('file', headerImageFile)
const { data } = await client.post(`/api/sites/${siteId}/header-image`, form)
setSite(data)
setHeaderImageFile(null)
toast.success('Header image uploaded')
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to upload header image')
} finally {
setUploadingHeaderImage(false)
}
}
async function doRemoveHeaderImage() {
setUploadingHeaderImage(true)
try {
const { data } = await client.delete(`/api/sites/${siteId}/header-image`)
setSite(data)
toast.success('Header image removed')
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to remove header image')
} finally {
setUploadingHeaderImage(false)
}
}
async function doDelete() {
setActing(true)
try {
@@ -281,6 +344,78 @@ export default function SiteDetailPage() {
</div>
</div>
{/* Menu Settings */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">QR Menu Settings</h2>
<button
onClick={() => {
setMenuMode(site.menu_mode || 'order')
setDisplayName(site.menu_display_name || '')
setTaglineEn(site.menu_tagline_en || '')
setTaglineGr(site.menu_tagline_gr || '')
setBlurbEn(site.menu_blurb_en || '')
setBlurbGr(site.menu_blurb_gr || '')
setHours(site.menu_hours || '')
setModal('menu_settings')
}}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
Edit
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Mode</span>
<span className={`font-medium ${site.menu_mode === 'view_only' ? 'text-yellow-400' : 'text-emerald-400'}`}>
{site.menu_mode === 'view_only' ? 'View Menu Only' : 'Ordering Enabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Display name</span>
<span className="text-gray-300">{site.menu_display_name || <span className="text-gray-600 italic">Not set</span>}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Header image</span>
<span className="text-gray-300">{site.menu_header_image_url ? 'Set' : '—'}</span>
</div>
</div>
{site.menu_header_image_url && (
<div className="mt-3 flex items-center gap-3">
<img
src={`${import.meta.env.VITE_CLOUD_URL || 'http://localhost:8001'}${site.menu_header_image_url}`}
alt="Menu header"
className="max-h-16 max-w-[60%] rounded-lg bg-gray-800 object-contain p-1.5"
/>
<button
onClick={doRemoveHeaderImage}
disabled={uploadingHeaderImage}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
)}
<div className="mt-3 flex items-center gap-2">
<input
type="file"
accept="image/png"
onChange={e => setHeaderImageFile(e.target.files?.[0] || null)}
className="flex-1 text-xs text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-gray-800 file:px-3 file:py-1.5 file:text-xs file:text-gray-300 hover:file:bg-gray-700"
/>
<button
onClick={doUploadHeaderImage}
disabled={!headerImageFile || uploadingHeaderImage}
className="px-3 py-1.5 text-xs font-medium bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 disabled:hover:bg-cyan-700 text-white rounded-lg transition-colors"
>
{uploadingHeaderImage ? 'Uploading…' : 'Upload'}
</button>
</div>
<p className="text-xs text-gray-600 mt-1.5">PNG only. Replaces the restaurant name on the menu header (shown at up to 80% width).</p>
</div>
{/* Lock status */}
{site.is_locked && (
<div className="bg-red-900/20 border border-red-800/50 rounded-xl p-4 mb-4">
@@ -326,8 +461,8 @@ export default function SiteDetailPage() {
{/* QR Codes */}
{(() => {
const slug = site.site_id
const menuUrl = `https://yourdomain.com/menu/${slug}`
const orderUrl = `https://yourdomain.com/menu/${slug}/order`
const menuUrl = `http://72.61.191.197:3100/menu/${slug}`
const orderUrl = `http://72.61.191.197:3100/menu/${slug}/order`
function handleDownload(canvasId, filename) {
const canvas = document.getElementById(canvasId)
@@ -496,6 +631,115 @@ export default function SiteDetailPage() {
</ConfirmModal>
)}
{modal === 'menu_settings' && (
<ConfirmModal
title="QR Menu Settings"
confirmLabel={acting ? 'Saving…' : 'Save'}
onCancel={() => setModal(null)}
onConfirm={doSaveMenuSettings}
>
<div className="space-y-3 mb-2">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Mode</label>
<div className="flex rounded-lg bg-gray-800 p-1 ring-1 ring-gray-600">
{[
{ value: 'order', label: 'Order' },
{ value: 'view_only', label: 'View Menu Only' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => setMenuMode(opt.value)}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
menuMode === opt.value
? 'bg-cyan-700 text-white'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs text-gray-500 mt-1.5">
"View Menu Only" hides all cart/ordering controls on the public QR menu customers can browse but not order.
</p>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Display name</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Olive & Thyme"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
<p className="text-xs text-gray-500 mt-1.5">
Shown on the public menu header. Separate from the internal site name above leave empty to show a generic "Our Menu" title (or hide it entirely once a header image is set).
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Tagline (EN)</label>
<input
type="text"
value={taglineEn}
onChange={e => setTaglineEn(e.target.value)}
placeholder="Kitchen & Bar"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Tagline (GR)</label>
<input
type="text"
value={taglineGr}
onChange={e => setTaglineGr(e.target.value)}
placeholder="Κουζίνα & Μπαρ"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Blurb (EN)</label>
<input
type="text"
value={blurbEn}
onChange={e => setBlurbEn(e.target.value)}
placeholder="Fresh seasonal plates, served with care."
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Blurb (GR)</label>
<input
type="text"
value={blurbGr}
onChange={e => setBlurbGr(e.target.value)}
placeholder="Εποχιακά πιάτα, με αγάπη."
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Hours</label>
<input
type="text"
value={hours}
onChange={e => setHours(e.target.value)}
placeholder="Open today · 12:00 23:30"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<p className="text-xs text-gray-500">Leave a field empty to hide it from the menu header.</p>
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"