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>
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="el">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3087
connect_frontend/menu-app/package-lock.json
generated
Normal file
3087
connect_frontend/menu-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,11 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import MenuPage from './pages/MenuPage'
|
import MenuPage from './pages/MenuPage'
|
||||||
import CartPage from './pages/CartPage'
|
|
||||||
import OrderConfirm from './pages/OrderConfirm'
|
import OrderConfirm from './pages/OrderConfirm'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/:siteSlug" element={<MenuPage />} />
|
<Route path="/:siteSlug" element={<MenuPage />} />
|
||||||
<Route path="/:siteSlug/order" element={<CartPage />} />
|
|
||||||
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
|
<Route path="/:siteSlug/confirm/:ref" element={<OrderConfirm />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
209
connect_frontend/menu-app/src/components/primitives.jsx
Normal file
209
connect_frontend/menu-app/src/components/primitives.jsx
Normal 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-[15px] text-[#b3aa97] line-through">{eur(base)}</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`font-display ${large ? 'text-[22px]' : 'text-[19px]'} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,4 +3,16 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { 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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
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'
|
import { fetchOrderStatus } from '../api'
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
const STATUS_CONFIG = {
|
||||||
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' },
|
pending_acceptance: { label: 'Waiting for confirmation', Icon: Clock, color: '#c9a24b', bg: '#f6edd6' },
|
||||||
accepted: { label: 'Order accepted!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
accepted: { label: 'Order accepted!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
rejected: { label: 'Order declined', Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' },
|
rejected: { label: 'Order declined', Icon: X, color: '#c2602f', bg: '#f7e6dc' },
|
||||||
preparing: { label: 'Being prepared', Icon: ChefHat, color: 'text-blue-500', bg: 'bg-blue-50' },
|
preparing: { label: 'Being prepared', Icon: ChefHat, color: '#2d3b2d', bg: '#e7ede7' },
|
||||||
ready: { label: 'Ready for pickup!', Icon: Package, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
ready: { label: 'Ready for pickup!', Icon: Package, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: 'text-blue-500', bg: 'bg-blue-50' },
|
out_for_delivery: { label: 'Out for delivery', Icon: Truck, color: '#2d3b2d', bg: '#e7ede7' },
|
||||||
delivered: { label: 'Delivered!', Icon: CheckCircle, color: 'text-emerald-500', bg: 'bg-emerald-50' },
|
delivered: { label: 'Delivered!', Icon: Check, color: '#3f7d4e', bg: '#e7f1e7' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL = new Set(['rejected', 'delivered'])
|
const TERMINAL = new Set(['rejected', 'delivered'])
|
||||||
@@ -42,48 +42,73 @@ export default function OrderConfirm() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [ref])
|
}, [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
|
const { label, Icon, color, bg } = cfg
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
|
<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="bg-white rounded-2xl shadow-sm p-8 w-full max-w-sm text-center space-y-5">
|
|
||||||
|
|
||||||
<div className={`w-20 h-20 ${bg} rounded-full flex items-center justify-center mx-auto`}>
|
{/* Ornament top */}
|
||||||
<Icon className={color} size={40} />
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
{/* Ornament bottom */}
|
||||||
<p className="text-xs text-slate-400 font-mono tracking-widest uppercase">{ref}</p>
|
<div className="mt-10 flex w-32 items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold text-slate-800">
|
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-[#d8cfb6]" />
|
||||||
{error ? 'Could not load status' : label}
|
<Leaf className="h-3 w-3 text-[#c9a24b]" strokeWidth={1.6} />
|
||||||
</h1>
|
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-[#d8cfb6]" />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,49 @@ export default {
|
|||||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: { sans: ['Geist', 'system-ui', 'sans-serif'] },
|
fontFamily: {
|
||||||
|
display: ['Bricolage Grotesque', 'serif'],
|
||||||
|
sans: ['Hanken Grotesk', '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: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user