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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user