Add guest/account auth flows, order tracking, and order history UI

- Wire up Better Auth (email/password + Google/Apple SSO) with a
  sign-in/sign-up UI, header account menu, and a basket that follows
  the signed-in-or-guest state.
- Give the order status page a vertical timeline per preparation step
  (with a distinct customer-facing name separate from the machine tag,
  a done/in-progress/queued/pending look, and a sonar-ping highlight on
  the active step), a formatted ETA (relative under 3h, absolute clock
  time beyond that), and Buy again / Get help / Cancel actions.
- Expand Pizza's recipe into 6 steps across a shared prep station and
  the existing oven/assembly stations, with matching schema, seed, and
  domain changes plus updated/added test coverage.
- Add an Orders list (account orders via session, guest orders via a
  locally remembered access-token list) with a shared getOrdersForCustomer
  domain summary, and remember a guest's name/contact after their first
  order so checkout doesn't ask again.
- Improve page transitions with the View Transitions API: a shared
  element morph between an order card and its detail page, a stable
  (non-jumping) header across navigations, and reduced-motion handling.
- Add a Ghost Kitchen wordmark (Space Grotesk, faded "Ghost", ghost
  icon standing in for the "o") and shrink the basket icon to match
  the rest of the header controls.
- Add a run-ghost-kitchen agent skill documenting local setup, the
  dev-server driver, and the gotchas hit along the way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:58:24 +02:00
co-authored by Claude Sonnet 5
parent 5059f40d4c
commit 79bd64ce8c
54 changed files with 3514 additions and 301 deletions
+31
View File
@@ -0,0 +1,31 @@
@use '../../styles/variables' as *;
.page {
max-width: 420px;
margin: 0 auto;
padding: $space-2 $space-3;
}
.card {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.heading {
margin: 0;
}
.footer {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
a {
color: $color-primary;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import styles from './AuthCard.module.scss'
type AuthCardProps = {
title: string
children: ReactNode
footer: ReactNode
}
export const AuthCard = ({ title, children, footer }: AuthCardProps) => (
<main className={styles.page}>
<div className={styles.card}>
<h1 className={styles.heading}>{title}</h1>
{children}
<p className={styles.footer}>{footer}</p>
</div>
</main>
)
@@ -0,0 +1,47 @@
@use '../../styles/variables' as *;
.buttons {
display: flex;
flex-direction: column;
gap: $space-2;
}
.google,
.apple {
display: flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-2 $space-3;
border-radius: $radius;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
.google {
background: $color-background;
color: #3c4043;
border: 1px solid $color-border;
&:hover {
background: $color-surface;
}
}
.apple {
background: #000;
color: #fff;
border: 1px solid #000;
&:hover {
background: #1a1a1a;
}
}
.icon {
height: 18px;
width: 18px;
flex-shrink: 0;
}
+75
View File
@@ -0,0 +1,75 @@
import { authClient } from '#/lib/auth-client.ts'
import styles from './SsoButtons.module.scss'
type Provider = 'google' | 'apple'
type SsoButtonsProps = {
onError: (message: string) => void
}
const continueWith = async (
provider: Provider,
onError: (message: string) => void,
) => {
const { error } = await authClient.signIn.social({
provider,
callbackURL: '/',
})
if (error) {
onError(error.message ?? `Could not continue with ${provider}.`)
}
}
const GoogleIcon = () => (
<svg className={styles.icon} viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2045c0-.6381-.0573-1.2518-.1636-1.8409H9v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.5404-1.8368.8595-3.0477.8595-2.3441 0-4.3273-1.5831-5.0345-3.7104H.9573v2.3318C2.4382 15.9832 5.4818 18 9 18z"
/>
<path
fill="#FBBC05"
d="M3.9655 10.71c-.18-.5404-.2823-1.1177-.2823-1.71s.1023-1.1696.2823-1.71V4.9582H.9573A8.9965 8.9965 0 0 0 0 9c0 1.4523.3477 2.8268.9573 4.0418L3.9655 10.71z"
/>
<path
fill="#EA4335"
d="M9 3.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C13.4632.8918 11.426 0 9 0 5.4818 0 2.4382 2.0168.9573 4.9582L3.9655 7.29C4.6727 5.1627 6.6559 3.5795 9 3.5795z"
/>
</svg>
)
const AppleIcon = () => (
<svg
className={styles.icon}
viewBox="0 0 384 512"
aria-hidden="true"
fill="currentColor"
>
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
)
export const SsoButtons = ({ onError }: SsoButtonsProps) => (
<div className={styles.buttons}>
<button
type="button"
className={styles.google}
onClick={() => continueWith('google', onError)}
>
<GoogleIcon />
Continue with Google
</button>
<button
type="button"
className={styles.apple}
onClick={() => continueWith('apple', onError)}
>
<AppleIcon />
Continue with Apple
</button>
</div>
)
@@ -0,0 +1,42 @@
@use '../../styles/variables' as *;
.trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
width: 2.25rem;
border: none;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.badge {
position: absolute;
top: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 3px;
border-radius: 999px;
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
}
+69
View File
@@ -0,0 +1,69 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useBasket } from '#/lib/basket.tsx'
import styles from './BasketLink.module.scss'
const MotionLink = motion.create(Link)
const BasketIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h2l2.4 12.2a2 2 0 0 0 2 1.8h8.4a2 2 0 0 0 2-1.6L22 9H6" />
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
</svg>
)
// Enter bounces in with a spring; exit uses a quick fixed-duration tween
// instead — a spring's slow settling tail would otherwise leave an
// invisible-but-still-interactive link sitting in the DOM for ~2s after it's
// visually gone.
const enterTransition = { type: 'spring', stiffness: 500, damping: 22 } as const
const exitTransition = { duration: 0.18, ease: 'easeIn' } as const
export const BasketLink = () => {
const { itemCount } = useBasket()
const reduceMotion = useReducedMotion()
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const onBasketPage = pathname === '/basket'
const pop = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1, transition: exitTransition },
exit: { opacity: 0, transition: exitTransition },
}
: {
initial: { opacity: 0, scale: 0.4 },
animate: { opacity: 1, scale: 1, transition: enterTransition },
exit: { opacity: 0, scale: 0.4, transition: exitTransition },
}
return (
<AnimatePresence>
{itemCount > 0 && !onBasketPage && (
<MotionLink
key="basket-link"
to="/basket"
className={styles.trigger}
aria-label={`Basket, ${itemCount} item${itemCount === 1 ? '' : 's'}`}
{...pop}
>
<BasketIcon />
<span className={styles.badge}>{itemCount}</span>
</MotionLink>
)}
</AnimatePresence>
)
}
@@ -43,3 +43,18 @@
font-size: 0.85rem;
margin: 0;
}
.forgetButton {
align-self: center;
padding: 0;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $color-text;
}
}
+58 -1
View File
@@ -1,6 +1,13 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import {
clearGuestIdentity,
getGuestIdentity,
saveGuestIdentity,
} from '#/lib/guestIdentity.ts'
import type { GuestIdentity } from '#/lib/guestIdentity.ts'
import { addGuestOrderRef } from '#/lib/guestOrders.ts'
import { placeOrder } from '#/server/orders.ts'
import styles from './CheckoutSection.module.scss'
@@ -25,14 +32,36 @@ export const CheckoutSection = ({
const { data: session, isPending } = authClient.useSession()
const [guestName, setGuestName] = useState('')
const [guestContact, setGuestContact] = useState('')
const [rememberedGuest, setRememberedGuest] = useState<GuestIdentity | null>(
null,
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Read after mount, not during initial render, so the server-rendered and
// first client-rendered HTML match (no localStorage on the server) and
// React doesn't flag a hydration mismatch — same reasoning as
// BasketProvider.
useEffect(() => {
setRememberedGuest(getGuestIdentity())
}, [])
const submit = async (customer: Customer) => {
setSubmitting(true)
setError(null)
try {
const order = await placeOrder({ data: { items, customer } })
if (order.guestAccessToken) {
addGuestOrderRef({
orderId: order.id,
guestAccessToken: order.guestAccessToken,
})
}
if (customer.type === 'guest') {
const identity = { name: customer.name, contact: customer.contact }
saveGuestIdentity(identity)
setRememberedGuest(identity)
}
onPlaced(order)
} catch (err) {
setError(
@@ -63,6 +92,34 @@ export const CheckoutSection = ({
)
}
if (rememberedGuest) {
return (
<div className={styles.section}>
<button
type="button"
className={styles.submitButton}
disabled={disabled || submitting}
onClick={() => submit({ type: 'guest', ...rememberedGuest })}
>
{submitting
? 'Placing order…'
: `Place order as ${rememberedGuest.name}`}
</button>
<button
type="button"
className={styles.forgetButton}
onClick={() => {
clearGuestIdentity()
setRememberedGuest(null)
}}
>
Not you?
</button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<form
className={styles.section}
@@ -54,6 +54,8 @@
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
align-self: center;
justify-self: center;
&:disabled {
opacity: 0.4;
@@ -1,35 +0,0 @@
@use '../../styles/variables' as *;
.empty {
color: $color-text-muted;
font-size: 0.9rem;
}
.summary {
display: flex;
flex-direction: column;
gap: $space-2;
}
.lines {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-1;
}
.line {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.total {
display: flex;
justify-content: space-between;
font-weight: 600;
padding-top: $space-2;
border-top: 1px solid $color-border;
}
-46
View File
@@ -1,46 +0,0 @@
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './OrderSummary.module.scss'
type OrderSummaryProps = {
menu: Array<MenuItemWithRecipe>
quantities: Record<number, number>
}
export const OrderSummary = ({ menu, quantities }: OrderSummaryProps) => {
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
if (lines.length === 0) {
return (
<p className={styles.empty}>
Your order is empty — add something from the menu.
</p>
)
}
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
0,
)
return (
<div className={styles.summary}>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
<span>
{quantity} × {item.name}
</span>
<span>${((item.priceCents * quantity) / 100).toFixed(2)}</span>
</li>
))}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
</div>
</div>
)
}
+147 -10
View File
@@ -6,6 +6,31 @@
padding: $space-5 $space-3;
}
.backLink {
display: inline-flex;
align-items: center;
gap: $space-1;
margin: 0 0 $space-3;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: none;
&:hover {
color: $color-primary;
.backArrow {
transform: translateX(-3px);
}
}
}
.backArrow {
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.heading {
margin: 0 0 $space-1;
}
@@ -48,22 +73,117 @@
color: $color-text-muted;
}
.steps {
.timeline {
margin: 0 0 $space-2;
padding-left: $space-4;
font-size: 0.85rem;
color: $color-text-muted;
padding: 0;
list-style: none;
}
.step {
&[data-status='done'] {
text-decoration: line-through;
position: relative;
display: grid;
grid-template-columns: $space-3 1fr;
column-gap: $space-2;
padding-bottom: $space-3;
font-size: 0.85rem;
color: $color-text-muted;
&:last-child {
padding-bottom: 0;
}
}
.stepMarker {
position: relative;
display: flex;
justify-content: center;
}
.stepDot {
position: relative;
width: 10px;
height: 10px;
margin-top: 3px;
border: 2px solid $color-border;
border-radius: 50%;
background: $color-background;
z-index: 1;
}
.stepMarker::after {
content: '';
position: absolute;
top: 15px;
bottom: -$space-3;
left: 50%;
width: 2px;
background: $color-border;
transform: translateX(-50%);
}
.step:last-child .stepMarker::after {
display: none;
}
.stepContent {
display: flex;
flex-direction: column;
gap: $space-1;
padding-top: 1px;
}
.stepMeta {
font-size: 0.75rem;
}
.step[data-status='done'] {
.stepDot {
border-color: $color-primary;
background: $color-primary;
}
&[data-status='in_progress'] {
color: $color-primary;
font-weight: 600;
.stepMarker::after {
background: $color-primary;
}
.stepLabel {
color: $color-text;
text-decoration: line-through;
}
}
.step[data-status='in_progress'] {
.stepDot {
border-color: $color-success;
background: $color-success;
@media (prefers-reduced-motion: no-preference) {
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: $color-success;
animation: sonarPing 1.6s ease-out infinite;
}
}
}
}
@keyframes sonarPing {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.8);
opacity: 0;
}
}
.step[data-status='pending'] {
opacity: 0.6;
}
.eta {
@@ -75,12 +195,16 @@
.actions {
display: flex;
gap: $space-2;
align-items: center;
button {
button,
a {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
text-decoration: none;
cursor: pointer;
&:disabled {
@@ -88,4 +212,17 @@
cursor: not-allowed;
}
}
.cancelButton {
padding: $space-1 $space-2;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: underline;
&:hover:not(:disabled) {
color: $color-danger;
}
}
}
+74 -24
View File
@@ -1,12 +1,37 @@
import { useRouter } from '@tanstack/react-router'
import { Link, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { formatEtaMessage } from '#/lib/format.ts'
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
const SUPPORT_EMAIL = 'help@ghostkitchen.example'
const helpMailto = (orderId: number, item: OrderItemStatusView) => {
const subject = `Help with Order #${orderId} — ${item.menuItemName}`
const body = `Order #${orderId}\nItem: ${item.menuItemName}\nStatus: ${item.status}\n\nWhat's going on?\n`
return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
}
const BackArrowIcon = () => (
<svg
className={styles.backArrow}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="M11 18l-6-6 6-6" />
</svg>
)
export type OrderStatusResult = {
id: number
status: OrderStatus
@@ -41,8 +66,14 @@ export const OrderStatusView = ({
return (
<section className={styles.page}>
<h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p>
<Link to="/orders" className={styles.backLink}>
<BackArrowIcon />
Back to orders
</Link>
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
<h1 className={styles.heading}>Order #{status.id}</h1>
<p className={styles.orderStatus}>Status: {status.status}</p>
</div>
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{status.items.map((item) => (
@@ -51,41 +82,43 @@ export const OrderStatusView = ({
<span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div>
<ol className={styles.steps}>
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
aria-current={step.status === 'in_progress' ? 'step' : undefined}
>
{step.machineTag}
{step.status === 'queued' &&
step.queuePosition !== null &&
` — #${step.queuePosition} in line`}
<span className={styles.stepMarker} aria-hidden="true">
<span className={styles.stepDot} />
</span>
<span className={styles.stepContent}>
<span className={styles.stepLabel}>{step.name}</span>
{step.status === 'queued' && step.queuePosition !== null && (
<span className={styles.stepMeta}>
#{step.queuePosition} in line
</span>
)}
</span>
</li>
))}
</ol>
{item.etaSeconds !== null && (
<p className={styles.eta}>
Ready in about {Math.ceil(item.etaSeconds / 60)} min
</p>
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
)}
<div className={styles.actions}>
{item.cancellable && (
<button
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
Cancel
</button>
)}
onClick={() => {
//
}}
>
Buy again
</button>
{item.status === 'ready' && (
<button
type="button"
@@ -101,6 +134,23 @@ export const OrderStatusView = ({
I picked this up
</button>
)}
<a href={helpMailto(status.id, item)}>Get help</a>
{item.cancellable && (
<button
type="button"
className={styles.cancelButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
cancelOrderItem({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
Cancel
</button>
)}
</div>
</li>
))}
@@ -0,0 +1,67 @@
@use '../../styles/variables' as *;
.page {
max-width: 720px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
a {
color: $color-primary;
}
}
.orders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.order {
display: block;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.orderHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-1;
}
.orderStatus {
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
}
.orderItems {
margin: 0 0 $space-1;
font-size: 0.9rem;
}
.orderMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
+61
View File
@@ -0,0 +1,61 @@
import { Link } from '@tanstack/react-router'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import styles from './OrdersListView.module.scss'
type OrdersListViewProps = {
orders: Array<CustomerOrderSummary>
}
const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`
const formatItems = (items: CustomerOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
)
.join(', ')
export const OrdersListView = ({ orders }: OrdersListViewProps) => (
<section className={styles.page}>
<h1 className={styles.heading}>Your orders</h1>
{orders.length === 0 ? (
<p className={styles.empty}>
You haven't placed an order yet — <Link to="/">browse the menu</Link>{' '}
to get started.
</p>
) : (
<ul className={styles.orders}>
{orders.map((order) => (
<li key={order.id}>
<Link
to="/orders/$orderId"
params={{ orderId: String(order.id) }}
search={
order.guestAccessToken
? { token: order.guestAccessToken }
: undefined
}
className={styles.order}
style={{ viewTransitionName: `order-card-${order.id}` }}
>
<div className={styles.orderHeader}>
<span className={styles.orderName}>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span>
</div>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
+17
View File
@@ -0,0 +1,17 @@
.logo {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.01em;
}
.faded {
font-weight: 500;
opacity: 0.45;
}
.ghostIcon {
display: inline-block;
height: 0.78em;
width: 0.78em;
vertical-align: -0.05em;
}
+60
View File
@@ -0,0 +1,60 @@
import { useId } from 'react'
import styles from './Logo.module.scss'
type LogoProps = {
name: string
}
const GhostIcon = () => {
const eyeMaskId = useId()
return (
<svg
className={styles.ghostIcon}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<mask id={eyeMaskId}>
<rect x="0" y="0" width="24" height="24" fill="#fff" />
<circle cx="9.5" cy="10.5" r="1.3" />
<circle cx="14.5" cy="10.5" r="1.3" />
</mask>
<path
d="M12 2C7.58 2 4 5.58 4 10v7l2.5 2.2L9 17l3 2.2 3-2.2 2.5 2.2L20 17v-7c0-4.42-3.58-8-8-8Z"
mask={`url(#${eyeMaskId})`}
/>
</svg>
)
}
// The brand's first word fades toward transparent — a literal "ghost" for a
// Kitchen whose name starts with one. When it actually is "Ghost", its "o"
// is swapped for a small ghost mark instead (a differently-named deployment
// just gets the fade).
export const Logo = ({ name }: LogoProps) => {
const [first, ...rest] = name.split(' ')
const restText = rest.join(' ')
if (!restText) return <span className={styles.logo}>{name}</span>
const oIndex = first.toLowerCase().indexOf('o')
return (
<span className={styles.logo}>
<span className={styles.faded}>
{oIndex === -1 ? (
first
) : (
<>
{first.slice(0, oIndex)}
<GhostIcon />
{first.slice(oIndex + 1)}
</>
)}
</span>{' '}
{restText}
</span>
)
}
+5
View File
@@ -55,6 +55,10 @@ export const menuItemSteps = pgTable('menu_item_steps', {
.notNull()
.references(() => menuItems.id),
sequence: integer().notNull(),
// Customer-facing label, e.g. "Flattening dough" — distinct from the
// Machine Tag because several Steps in a Recipe can share one Tag (and
// so the same physical Machine) while doing different work on it.
name: text().notNull(),
machineTagId: integer('machine_tag_id')
.notNull()
.references(() => machineTags.id),
@@ -112,6 +116,7 @@ export const orderItemSteps = pgTable('order_item_steps', {
.notNull()
.references(() => orderItems.id),
sequence: integer().notNull(),
name: text().notNull(),
machineTagId: integer('machine_tag_id')
.notNull()
.references(() => machineTags.id),
+61 -8
View File
@@ -11,6 +11,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Ghost Kitchen' })
.returning()
const [prepTag] = await db
.insert(schema.machineTags)
.values({ name: 'prep' })
.returning()
const [ovenTag] = await db
.insert(schema.machineTags)
.values({ name: 'oven' })
@@ -24,6 +28,10 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'assembly' })
.returning()
const [prep1] = await db
.insert(schema.machines)
.values({ name: 'Prep Station 1', machineTagId: prepTag.id })
.returning()
const [oven1] = await db
.insert(schema.machines)
.values({ name: 'Oven 1', machineTagId: ovenTag.id })
@@ -47,12 +55,50 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
})
.returning()
await db.insert(schema.menuItemSteps).values({
menuItemId: pizza.id,
sequence: 1,
machineTagId: ovenTag.id,
estimatedDurationSeconds: 600,
})
await db.insert(schema.menuItemSteps).values([
{
menuItemId: pizza.id,
sequence: 1,
name: 'Flattening dough',
machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 2,
name: 'Spreading sauce',
machineTagId: prepTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 3,
name: 'Adding toppings',
machineTagId: prepTag.id,
estimatedDurationSeconds: 30,
},
{
menuItemId: pizza.id,
sequence: 4,
name: 'Baking in the oven',
machineTagId: ovenTag.id,
estimatedDurationSeconds: 600,
},
{
menuItemId: pizza.id,
sequence: 5,
name: 'Slicing',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
{
menuItemId: pizza.id,
sequence: 6,
name: 'Packaging',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 20,
},
])
const [burger] = await db
.insert(schema.menuItems)
@@ -68,12 +114,14 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
{
menuItemId: burger.id,
sequence: 1,
name: 'Grilling patty',
machineTagId: grillTag.id,
estimatedDurationSeconds: 240,
},
{
menuItemId: burger.id,
sequence: 2,
name: 'Assembling burger',
machineTagId: assemblyTag.id,
estimatedDurationSeconds: 90,
},
@@ -81,8 +129,13 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
return {
brand,
machineTags: { oven: ovenTag, grill: grillTag, assembly: assemblyTag },
machines: { oven1, grill1, assembly1 },
machineTags: {
prep: prepTag,
oven: ovenTag,
grill: grillTag,
assembly: assemblyTag,
},
machines: { prep1, oven1, grill1, assembly1 },
menuItems: { pizza, burger },
}
}
+123 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from 'drizzle-orm'
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
@@ -53,6 +53,7 @@ export type MenuItemWithRecipe = {
priceCents: number
steps: Array<{
sequence: number
name: string
machineTag: string
estimatedDurationSeconds: number
}>
@@ -71,6 +72,7 @@ export const getMenu = async (
.select({
menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence,
name: schema.menuItemSteps.name,
machineTag: schema.machineTags.name,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
})
@@ -94,8 +96,9 @@ export const getMenu = async (
priceCents: item.priceCents,
steps: steps
.filter((step) => step.menuItemId === item.id)
.map(({ sequence, machineTag, estimatedDurationSeconds }) => ({
.map(({ sequence, name, machineTag, estimatedDurationSeconds }) => ({
sequence,
name,
machineTag,
estimatedDurationSeconds,
})),
@@ -199,6 +202,7 @@ const initializeOrderItemRecipe = async (
liveSteps.map((step) => ({
orderItemId,
sequence: step.sequence,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
@@ -582,6 +586,7 @@ export type OrderItemStatusView = {
cancellable: boolean
steps: Array<{
sequence: number
name: string
machineTag: string
estimatedDurationSeconds: number
status: 'done' | 'in_progress' | 'queued' | 'pending'
@@ -625,6 +630,7 @@ export const getOrderStatus = async (
.select({
orderItemId: schema.orderItemSteps.orderItemId,
sequence: schema.orderItemSteps.sequence,
name: schema.orderItemSteps.name,
machineTag: schema.machineTags.name,
machineTagId: schema.orderItemSteps.machineTagId,
estimatedDurationSeconds:
@@ -710,6 +716,7 @@ export const getOrderStatus = async (
if (etaSeconds !== null) etaSeconds += step.estimatedDurationSeconds
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'pending' as const,
@@ -720,6 +727,7 @@ export const getOrderStatus = async (
if (instance.status === 'done') {
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'done' as const,
@@ -739,6 +747,7 @@ export const getOrderStatus = async (
}
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'in_progress' as const,
@@ -765,6 +774,7 @@ export const getOrderStatus = async (
return {
sequence: step.sequence,
name: step.name,
machineTag: step.machineTag,
estimatedDurationSeconds: step.estimatedDurationSeconds,
status: 'queued' as const,
@@ -802,3 +812,114 @@ export const getOrderStatus = async (
items: itemViews,
}
}
export type CustomerOrderSummary = {
id: number
status: schema.OrderStatus
createdAt: Date
totalCents: number
items: Array<{ menuItemName: string; quantity: number }>
// Only set for a guest Order — its detail page needs this back as a
// search param to prove access, since there's no session to check.
guestAccessToken: string | null
}
const summarizeOrders = async (
db: Db,
orders: Array<typeof schema.orders.$inferSelect>,
): Promise<Array<CustomerOrderSummary>> => {
if (orders.length === 0) return []
const items = await db
.select({
orderId: schema.orderItems.orderId,
menuItemName: schema.menuItems.name,
priceCents: schema.orderItems.priceCents,
})
.from(schema.orderItems)
.innerJoin(
schema.menuItems,
eq(schema.orderItems.menuItemId, schema.menuItems.id),
)
.where(
inArray(
schema.orderItems.orderId,
orders.map((order) => order.id),
),
)
return orders.map((order) => {
const orderItems = items.filter((item) => item.orderId === order.id)
const quantityByMenuItemName = new Map<string, number>()
for (const item of orderItems) {
quantityByMenuItemName.set(
item.menuItemName,
(quantityByMenuItemName.get(item.menuItemName) ?? 0) + 1,
)
}
return {
id: order.id,
status: order.status,
createdAt: order.createdAt,
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
menuItemName,
quantity,
})),
guestAccessToken: order.guestAccessToken,
}
})
}
// A signed-in customer's own Order history — guest Orders aren't included
// since they have no account to list under, only a per-Order access token
// (see getGuestOrderSummaries for how a guest's own history is looked up).
export const getOrdersForCustomer = async (
db: Db,
customerId: string,
): Promise<Array<CustomerOrderSummary>> => {
const orders = await db
.select()
.from(schema.orders)
.where(eq(schema.orders.customerId, customerId))
.orderBy(desc(schema.orders.createdAt))
return summarizeOrders(db, orders)
}
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
// A guest's Order history, looked up from the (orderId, guestAccessToken)
// pairs their browser remembered locally after each placement — there's no
// account to query by, so each ref's token stands in for one. A ref whose
// token doesn't match (stale, tampered, or the Order somehow isn't a guest
// Order) is silently dropped rather than failing the whole lookup.
export const getGuestOrderSummaries = async (
db: Db,
refs: Array<GuestOrderRef>,
): Promise<Array<CustomerOrderSummary>> => {
if (refs.length === 0) return []
const tokenByOrderId = new Map(
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
)
const orders = await db
.select()
.from(schema.orders)
.where(
inArray(schema.orders.id, [...tokenByOrderId.keys()]),
)
const authorizedOrders = orders.filter(
(order) =>
order.guestAccessToken !== null &&
tokenByOrderId.get(order.id) === order.guestAccessToken,
)
const summaries = await summarizeOrders(db, authorizedOrders)
return summaries.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
)
}
@@ -1,44 +1,136 @@
@use '../../styles/variables' as *;
.avatar {
height: 2rem;
width: 2rem;
background: $color-surface;
.wrapper {
position: relative;
}
.avatarPulse {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarInitial {
.trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: $color-text-muted;
}
.session {
display: flex;
align-items: center;
gap: $space-2;
}
.signOut {
flex: 1;
height: 2.25rem;
padding: 0 $space-3;
font-size: 0.875rem;
font-weight: 500;
background: $color-background;
color: $color-text;
width: 2.25rem;
padding: 0;
border: 1px solid $color-border;
border-radius: 50%;
background: $color-background;
color: $color-text-muted;
cursor: pointer;
overflow: hidden;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
svg {
height: 1.25rem;
width: 1.25rem;
}
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
.avatarImage {
height: 100%;
width: 100%;
object-fit: cover;
}
.avatarInitial {
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menu {
position: absolute;
top: calc(100% + #{$space-2});
right: 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 11rem;
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
z-index: 10;
}
.menuLabel {
padding: $space-2;
border-bottom: 1px solid $color-border;
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: $color-text;
}
.menuButton {
display: flex;
align-items: center;
gap: $space-2;
width: 100%;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: none;
color: $color-text;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
}
.loginButton {
display: block;
width: 100%;
margin: $space-1 0;
padding: $space-2;
border: none;
border-radius: calc($radius - 2px);
background: $color-primary;
color: $color-primary-contrast;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.signupLink {
display: block;
width: 100%;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.8rem;
text-align: center;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.menuIcon {
height: 1.1rem;
width: 1.1rem;
flex-shrink: 0;
}
@keyframes pulse {
+135 -21
View File
@@ -1,37 +1,151 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { authClient } from '#/lib/auth-client'
import styles from './header-user.module.scss'
const AccountIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="8" r="4" strokeWidth="1.5" />
<path d="M4 20c0-4 3.5-7 8-7s8 3 8 7" strokeWidth="1.5" />
</svg>
)
const OrdersIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 3h6l1 3H8l1-3Z" />
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
<path d="M9 11h6" />
</svg>
)
const LogOutIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
)
const BetterAuthHeader = () => {
const { data: session, isPending } = authClient.useSession()
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
useEffect(() => {
setOpen(false)
}, [pathname])
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
if (isPending) {
return <div className={`${styles.avatar} ${styles.avatarPulse}`} />
return <div className={`${styles.trigger} ${styles.pulse}`} />
}
if (session?.user) {
return (
<div className={styles.session}>
{session.user.image ? (
<img src={session.user.image} alt="" className={styles.avatar} />
return (
<div className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.trigger}
onClick={() => setOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={
session?.user ? `Account menu for ${session.user.name}` : 'Account'
}
>
{session?.user ? (
session.user.image ? (
<img src={session.user.image} alt="" className={styles.avatarImage} />
) : (
<span className={styles.avatarInitial}>
{session.user.name.charAt(0).toUpperCase() || 'U'}
</span>
)
) : (
<div className={`${styles.avatar} ${styles.avatarInitial}`}>
<span>{session.user.name.charAt(0).toUpperCase() || 'U'}</span>
</div>
<AccountIcon />
)}
<button
onClick={() => {
void authClient.signOut()
}}
className={styles.signOut}
>
Sign out
</button>
</div>
)
}
</button>
return null
{open && (
<div className={styles.menu} role="menu">
{session?.user ? (
<>
<p className={styles.menuLabel}>{session.user.name}</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<button
type="button"
role="menuitem"
className={styles.menuButton}
onClick={() => {
setOpen(false)
void authClient.signOut()
}}
>
<LogOutIcon />
Sign out
</button>
</>
) : (
<>
<p className={styles.menuLabel}>Guest</p>
<Link to="/orders" role="menuitem" className={styles.menuButton}>
<OrdersIcon />
Orders
</Link>
<Link to="/login" role="menuitem" className={styles.loginButton}>
Sign in
</Link>
<Link to="/signup" role="menuitem" className={styles.signupLink}>
New here? Sign up
</Link>
</>
)}
</div>
)}
</div>
)
}
export default BetterAuthHeader
+20
View File
@@ -2,10 +2,30 @@ import { betterAuth } from 'better-auth'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { Pool } from 'pg'
// Each provider is only registered once its credentials are configured, so
// local dev without OAuth apps set up still works via email/password.
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: {
enabled: true,
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET
? {
apple: {
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET,
},
}
: {}),
},
plugins: [tanstackStartCookies()],
})
+90
View File
@@ -0,0 +1,90 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
const STORAGE_KEY = 'ghost-kitchen-basket'
type BasketContextValue = {
quantities: Record<number, number>
itemCount: number
add: (menuItemId: number) => void
remove: (menuItemId: number) => void
clear: () => void
}
const BasketContext = createContext<BasketContextValue | null>(null)
const readStoredQuantities = (): Record<number, number> => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<number, number>) : {}
} catch {
return {}
}
}
export const BasketProvider = ({ children }: { children: ReactNode }) => {
const [quantities, setQuantities] = useState<Record<number, number>>({})
// Read from localStorage after mount, not during initial render, so the
// server-rendered and first client-rendered HTML match (no localStorage on
// the server) and React doesn't flag a hydration mismatch.
useEffect(() => {
setQuantities(readStoredQuantities())
}, [])
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quantities))
} catch {
// Unavailable (private mode, quota) — basket still works for this
// page load, it just won't persist across a reload.
}
}, [quantities])
const add = (menuItemId: number) =>
setQuantities((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
}))
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const next = { ...prev }
delete next[menuItemId]
return next
}
return { ...prev, [menuItemId]: current - 1 }
})
const clear = () => setQuantities({})
const itemCount = useMemo(
() => Object.values(quantities).reduce((sum, quantity) => sum + quantity, 0),
[quantities],
)
const value = useMemo(
() => ({ quantities, itemCount, add, remove, clear }),
[quantities, itemCount],
)
return (
<BasketContext.Provider value={value}>{children}</BasketContext.Provider>
)
}
export const useBasket = () => {
const context = useContext(BasketContext)
if (!context) {
throw new Error('useBasket must be used within a BasketProvider')
}
return context
}
+31
View File
@@ -0,0 +1,31 @@
export const formatDuration = (totalSeconds: number) => {
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
if (minutes === 0) return `${hours} hr`
return `${hours} hr ${minutes} min`
}
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate())
export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const target = new Date(now.getTime() + etaSeconds * 1000)
const time = target.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const dayDiff = Math.round(
(startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000,
)
if (dayDiff === 0) return `today, ${time}`
if (dayDiff === 1) return `tomorrow, ${time}`
return `${target.toLocaleDateString([], { weekday: 'long' })}, ${time}`
}
const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60
export const formatEtaMessage = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}`
: `Ready in about ${formatDuration(etaSeconds)}`
+39
View File
@@ -0,0 +1,39 @@
// A guest's name/contact, remembered locally so checkout doesn't ask again
// on their next order from this browser.
const STORAGE_KEY = 'ghost-kitchen-guest-identity'
export type GuestIdentity = { name: string; contact: string }
const isGuestIdentity = (value: unknown): value is GuestIdentity =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestIdentity).name === 'string' &&
typeof (value as GuestIdentity).contact === 'string'
export const getGuestIdentity = (): GuestIdentity | null => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isGuestIdentity(parsed) ? parsed : null
} catch {
return null
}
}
export const saveGuestIdentity = (identity: GuestIdentity) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identity))
} catch {
// Unavailable (private mode, quota) — checkout still works, it just
// asks again next time.
}
}
export const clearGuestIdentity = () => {
try {
window.localStorage.removeItem(STORAGE_KEY)
} catch {
// Unavailable — nothing to clear.
}
}
+38
View File
@@ -0,0 +1,38 @@
// Guests have no account to list Orders under, so their Order history is
// tracked client-side instead: each placed guest Order's id + access token
// is remembered here so `/orders` can look them back up without a sign-in.
const STORAGE_KEY = 'ghost-kitchen-guest-orders'
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
const isGuestOrderRef = (value: unknown): value is GuestOrderRef =>
typeof value === 'object' &&
value !== null &&
typeof (value as GuestOrderRef).orderId === 'number' &&
typeof (value as GuestOrderRef).guestAccessToken === 'string'
export const getGuestOrderRefs = (): Array<GuestOrderRef> => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isGuestOrderRef) : []
} catch {
return []
}
}
export const addGuestOrderRef = (ref: GuestOrderRef) => {
try {
const existing = getGuestOrderRefs().filter(
(stored) => stored.orderId !== ref.orderId,
)
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify([ref, ...existing]),
)
} catch {
// Unavailable (private mode, quota) — the order still works via its
// own link, it just won't show up in the local Orders list.
}
}
+97 -3
View File
@@ -10,6 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -18,6 +22,26 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const BasketRoute = BasketRouteImport.update({
id: '/basket',
path: '/basket',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersIndexRoute = OrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => rootRouteImport,
} as any)
const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
id: '/orders/$orderId',
path: '/orders/$orderId',
@@ -31,31 +55,69 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/orders/$orderId': typeof OrdersOrderIdRoute
'/orders/': typeof OrdersIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/orders/$orderId' | '/api/auth/$'
fullPaths:
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/orders/$orderId' | '/api/auth/$'
id: '__root__' | '/' | '/orders/$orderId' | '/api/auth/$'
to:
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders'
| '/api/auth/$'
id:
| '__root__'
| '/'
| '/basket'
| '/login'
| '/signup'
| '/orders/$orderId'
| '/orders/'
| '/api/auth/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BasketRoute: typeof BasketRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
OrdersIndexRoute: typeof OrdersIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
}
@@ -68,6 +130,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/basket': {
id: '/basket'
path: '/basket'
fullPath: '/basket'
preLoaderRoute: typeof BasketRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/': {
id: '/orders/'
path: '/orders'
fullPath: '/orders/'
preLoaderRoute: typeof OrdersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/orders/$orderId': {
id: '/orders/$orderId'
path: '/orders/$orderId'
@@ -87,7 +177,11 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BasketRoute: BasketRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
OrdersOrderIdRoute: OrdersOrderIdRoute,
OrdersIndexRoute: OrdersIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
}
export const routeTree = rootRouteImport
+1
View File
@@ -7,6 +7,7 @@ export const getRouter = () => {
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultViewTransition: true,
})
return router
+30
View File
@@ -0,0 +1,30 @@
@use '../styles/variables' as *;
.topBar {
position: relative;
// Explicit stacking context, ranked above any page content: setting
// `view-transition-name` on an element (here and on OrderStatusView's
// order card) implicitly makes it its own stacking context too — without
// this, whichever one comes later in the DOM wins ties on z-index:0, so
// the account menu dropdown was rendering underneath the order card on
// the order status page.
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-3;
border-bottom: 1px solid $color-border;
}
.brand {
font-size: 1.1rem;
font-weight: 700;
color: $color-text;
text-decoration: none;
}
.actions {
display: flex;
align-items: center;
gap: $space-2;
}
+67 -24
View File
@@ -1,33 +1,64 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import {
HeadContent,
Link,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import '../styles/globals.scss'
import { BasketLink } from '#/components/basket/BasketLink.tsx'
import { Logo } from '#/components/ui/Logo.tsx'
import BetterAuthHeader from '#/integrations/better-auth/header-user.tsx'
import { BasketProvider } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
const RootDocument = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
import '../styles/globals.scss'
import styles from './__root.module.scss'
const RootDocument = ({ children }: { children: React.ReactNode }) => {
const { brand } = Route.useLoaderData()
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<BasketProvider>
<div
className={styles.topBar}
style={{ viewTransitionName: 'site-header' }}
>
<Link to="/" className={styles.brand}>
<Logo name={brand.name} />
</Link>
<div className={styles.actions}>
<BasketLink />
<BetterAuthHeader />
</div>
</div>
{children}
</BasketProvider>
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
}
export const Route = createRootRoute({
loader: async () => ({ brand: await getBrand() }),
head: () => ({
meta: [
{
@@ -41,6 +72,18 @@ export const Route = createRootRoute({
title: 'Ghost Kitchen',
},
],
links: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap',
},
],
}),
shellComponent: RootDocument,
})
+87
View File
@@ -0,0 +1,87 @@
@use '../styles/variables' as *;
.page {
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
a {
color: $color-primary;
}
}
.lines {
list-style: none;
margin: 0 0 $space-3;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-2;
}
.line {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.lineInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.lineName {
font-weight: 600;
}
.linePrice {
color: $color-text-muted;
font-size: 0.9rem;
}
.quantity {
display: flex;
align-items: center;
gap: $space-2;
}
.stepperButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-background;
cursor: pointer;
align-self: center;
justify-self: center;
}
.count {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.total {
display: flex;
justify-content: space-between;
font-weight: 600;
padding: $space-3 0;
margin-bottom: $space-3;
border-top: 1px solid $color-border;
border-bottom: 1px solid $color-border;
}
+103
View File
@@ -0,0 +1,103 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './basket.module.scss'
const BasketPage = () => {
const { menu } = Route.useLoaderData()
const { quantities, add, remove, clear } = useBasket()
const navigate = useNavigate()
const lines = menu
.map((item) => ({ item, quantity: quantities[item.id] ?? 0 }))
.filter(({ quantity }) => quantity > 0)
const totalCents = lines.reduce(
(sum, { item, quantity }) => sum + item.priceCents * quantity,
0,
)
const items = lines.flatMap(({ item, quantity }) =>
Array.from({ length: quantity }, () => ({ menuItemId: item.id })),
)
return (
<main className={styles.page}>
<h1 className={styles.heading}>Your basket</h1>
{lines.length === 0 ? (
<p className={styles.empty}>
Your basket is empty — <Link to="/">browse the menu</Link> to add
something.
</p>
) : (
<>
<ul className={styles.lines}>
{lines.map(({ item, quantity }) => (
<li key={item.id} className={styles.line}>
<div className={styles.lineInfo}>
<span className={styles.lineName}>{item.name}</span>
<span className={styles.linePrice}>
${((item.priceCents * quantity) / 100).toFixed(2)}
</span>
</div>
<div className={styles.quantity}>
<button
type="button"
className={styles.stepperButton}
onClick={() => remove(item.id)}
aria-label={`Remove one ${item.name}`}
>
−
</button>
<span className={styles.count}>{quantity}</span>
<button
type="button"
className={styles.stepperButton}
onClick={() => add(item.id)}
aria-label={`Add one ${item.name}`}
>
+
</button>
</div>
</li>
))}
</ul>
<div className={styles.total}>
<span>Total</span>
<span>${(totalCents / 100).toFixed(2)}</span>
</div>
<CheckoutSection
items={items}
disabled={items.length === 0}
onPlaced={async (order) => {
await navigate({
to: '/orders/$orderId',
params: { orderId: String(order.id) },
search: order.guestAccessToken
? { token: order.guestAccessToken }
: {},
})
clear()
}}
/>
</>
)}
</main>
)
}
export const Route = createFileRoute('/basket')({
loader: async () => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
return { menu }
},
component: BasketPage,
})
+1 -26
View File
@@ -1,32 +1,7 @@
@use '../styles/variables' as *;
.page {
max-width: 960px;
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: $space-4;
@media (min-width: $breakpoint-tablet) {
grid-template-columns: 2fr 1fr;
}
}
.sidebar {
display: flex;
flex-direction: column;
gap: $space-3;
align-self: start;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
+11 -57
View File
@@ -1,69 +1,24 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { MenuList } from '#/components/ordering/MenuList.tsx'
import { OrderSummary } from '#/components/ordering/OrderSummary.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
import styles from './index.module.scss'
const OrderingPage = () => {
const { brand, menu } = Route.useLoaderData()
const navigate = useNavigate()
const [quantities, setQuantities] = useState<Record<number, number>>({})
const add = (menuItemId: number) =>
setQuantities((prev) => ({
...prev,
[menuItemId]: (prev[menuItemId] ?? 0) + 1,
}))
const remove = (menuItemId: number) =>
setQuantities((prev) => {
const current = prev[menuItemId] ?? 0
if (current <= 1) {
const next = { ...prev }
delete next[menuItemId]
return next
}
return { ...prev, [menuItemId]: current - 1 }
})
const items = Object.entries(quantities).flatMap(([menuItemId, quantity]) =>
Array.from({ length: quantity }, () => ({
menuItemId: Number(menuItemId),
})),
)
const { menu } = Route.useLoaderData()
const { quantities, add, remove } = useBasket()
return (
<main className={styles.page}>
<h1 className={styles.heading}>{brand.name}</h1>
<div className={styles.layout}>
<MenuList
menu={menu}
quantities={quantities}
onAdd={add}
onRemove={remove}
/>
<aside className={styles.sidebar}>
<OrderSummary menu={menu} quantities={quantities} />
<CheckoutSection
items={items}
disabled={items.length === 0}
onPlaced={(order) =>
navigate({
to: '/orders/$orderId',
params: { orderId: String(order.id) },
search: order.guestAccessToken
? { token: order.guestAccessToken }
: {},
})
}
/>
</aside>
</div>
<MenuList
menu={menu}
quantities={quantities}
onAdd={add}
onRemove={remove}
/>
</main>
)
}
@@ -71,8 +26,7 @@ const OrderingPage = () => {
export const Route = createFileRoute('/')({
loader: async () => {
const brand = await getBrand()
const menu = await getMenu({ data: { brandId: brand.id } })
return { brand, menu }
return { menu: await getMenu({ data: { brandId: brand.id } }) }
},
component: OrderingPage,
})
+50
View File
@@ -0,0 +1,50 @@
@use '../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.divider {
text-align: center;
font-size: 0.85rem;
color: $color-text-muted;
}
+81
View File
@@ -0,0 +1,81 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { AuthCard } from '#/components/auth/AuthCard.tsx'
import { SsoButtons } from '#/components/auth/SsoButtons.tsx'
import { authClient } from '#/lib/auth-client.ts'
import styles from './login.module.scss'
const LoginPage = () => {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setError(null)
const { error: signInError } = await authClient.signIn.email({
email,
password,
})
setSubmitting(false)
if (signInError) {
setError(signInError.message ?? 'Could not sign in. Please try again.')
return
}
void navigate({ to: '/' })
}
return (
<AuthCard
title="Sign in"
footer={
<>
Don&apos;t have an account? <Link to="/signup">Sign up</Link>
</>
}
>
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={submitting}
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
<div className={styles.divider}>or</div>
<SsoButtons onError={setError} />
</AuthCard>
)
}
export const Route = createFileRoute('/login')({
component: LoginPage,
})
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { OrdersListView } from '#/components/orders/OrdersListView.tsx'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { getGuestOrderRefs } from '#/lib/guestOrders.ts'
import { getGuestOrders, getMyOrders } from '#/server/orders.ts'
const OrdersRoute = () => {
const accountOrders = Route.useLoaderData()
const [guestOrders, setGuestOrders] = useState<Array<
CustomerOrderSummary
> | null>(null)
// Guest history lives in localStorage, so it can only be read client-side
// — only attempted once the loader has confirmed there's no account.
useEffect(() => {
if (accountOrders !== null) return
const refs = getGuestOrderRefs()
if (refs.length === 0) {
setGuestOrders([])
return
}
void getGuestOrders({ data: { refs } }).then(setGuestOrders)
}, [accountOrders])
const orders = accountOrders ?? guestOrders
if (orders === null) return null
return <OrdersListView orders={orders} />
}
export const Route = createFileRoute('/orders/')({
loader: () => getMyOrders().catch(() => null),
component: OrdersRoute,
})
+50
View File
@@ -0,0 +1,50 @@
@use '../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
}
}
.submitButton {
padding: $space-2 $space-3;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.divider {
text-align: center;
font-size: 0.85rem;
color: $color-text-muted;
}
+94
View File
@@ -0,0 +1,94 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { AuthCard } from '#/components/auth/AuthCard.tsx'
import { SsoButtons } from '#/components/auth/SsoButtons.tsx'
import { authClient } from '#/lib/auth-client.ts'
import styles from './signup.module.scss'
const SignupPage = () => {
const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setError(null)
const { error: signUpError } = await authClient.signUp.email({
name,
email,
password,
})
setSubmitting(false)
if (signUpError) {
setError(
signUpError.message ?? 'Could not create an account. Please try again.',
)
return
}
void navigate({ to: '/' })
}
return (
<AuthCard
title="Create an account"
footer={
<>
Already have an account? <Link to="/login">Sign in</Link>
</>
}
>
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={submitting}
>
{submitting ? 'Creating account…' : 'Create account'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
<div className={styles.divider}>or</div>
<SsoButtons onError={setError} />
</AuthCard>
)
}
export const Route = createFileRoute('/signup')({
component: SignupPage,
})
+22
View File
@@ -62,6 +62,28 @@ export const getOrderStatus = createServerFn({ method: 'GET' })
ordering.getOrderStatus(db, data.orderId, await resolveAccess(data)),
)
export const getMyOrders = createServerFn({ method: 'GET' }).handler(
async () => {
const customerId = await getSessionCustomerId()
if (!customerId) {
throw new Error('You must be signed in to view your orders')
}
return ordering.getOrdersForCustomer(db, customerId)
},
)
export const getGuestOrders = createServerFn({ method: 'GET' })
.validator(
z.object({
refs: z.array(
z.object({ orderId: z.number(), guestAccessToken: z.string() }),
),
}),
)
.handler(async ({ data }) =>
ordering.getGuestOrderSummaries(db, data.refs),
)
export const cancelOrderItem = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
+77
View File
@@ -21,3 +21,80 @@ body {
button {
font: inherit;
}
// Route navigation transitions, powered by the router's defaultViewTransition
// (see router.tsx). No-ops in browsers without View Transitions support.
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.22s;
animation-timing-function: ease;
}
::view-transition-old(root) {
animation-name: view-transition-fade-out;
}
::view-transition-new(root) {
animation-name: view-transition-fade-in;
}
// Any element that carries a matching `view-transition-name` on both the
// outgoing and incoming page (e.g. an Order card morphing into its detail
// header, see OrdersListView / OrderStatusView) gets its own transition
// group — this smooths and slows the browser's default morph/resize
// animation for those to match the root crossfade's pace instead of the
// UA default's snappier one.
::view-transition-group(*) {
animation-duration: 0.32s;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
// The top nav bar (see __root.tsx) carries its own `view-transition-name`
// so it's excluded from the root snapshot above — otherwise the root
// fade's translateY drags the persistent header along with it, making it
// visibly jump on every navigation. It doesn't change between routes, so
// it should just stay put: no morph, no crossfade.
::view-transition-group(site-header) {
animation: none;
}
::view-transition-old(site-header),
::view-transition-new(site-header) {
animation: none;
}
}
// document.startViewTransition() itself doesn't check this preference —
// without an explicit override every browser falls back to its own default
// crossfade/morph animations, unanimated only for the rules above (which
// only apply under no-preference). Kill all of it outright here instead.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
@keyframes view-transition-fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes view-transition-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-6px);
}
}