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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ export const getRouter = () => {
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultViewTransition: true,
|
||||
})
|
||||
|
||||
return router
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user