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
@@ -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