Add a Help page with FAQ and contact form

Adds a /help route with an animated FAQ accordion and a contact form
(persisted via a new contact_messages table). The per-order-item "Get
help" link now points here and passes the order/item/status as search
params, which the page uses to show a short, state-specific note and
prefill the message field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:24:51 +02:00
co-authored by Claude Sonnet 5
parent 7f3728b5bf
commit a8e655a810
11 changed files with 1200 additions and 9 deletions
+10 -9
View File
@@ -8,14 +8,6 @@ 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 cancelBlockedReason = (item: OrderItemStatusView) => {
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
return null
@@ -186,7 +178,16 @@ export const OrderStatusView = ({
I picked this up
</button>
)}
<a href={helpMailto(status.id, item)}>Get help</a>
<Link
to="/help"
search={{
orderId: status.id,
item: item.menuItemName,
status: item.status,
}}
>
Get help
</Link>
{cancelHint && (
<>
<button
+9
View File
@@ -168,3 +168,12 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
startedAt: timestamp('started_at'),
doneAt: timestamp('done_at'),
})
// A message submitted through the Help page's contact form.
export const contactMessages = pgTable('contact_messages', {
id: serial().primaryKey(),
name: text().notNull(),
email: text().notNull(),
message: text().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+21
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as HelpRouteImport } from './routes/help'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
@@ -41,6 +42,11 @@ const BasketRoute = BasketRouteImport.update({
path: '/basket',
getParentRoute: () => rootRouteImport,
} as any)
const HelpRoute = HelpRouteImport.update({
id: '/help',
path: '/help',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -111,6 +117,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
@@ -128,6 +135,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
@@ -147,6 +155,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
@@ -167,6 +176,7 @@ export interface FileRouteTypes {
| '/'
| '/admin'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
@@ -184,6 +194,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
@@ -202,6 +213,7 @@ export interface FileRouteTypes {
| '/'
| '/admin'
| '/basket'
| '/help'
| '/login'
| '/signup'
| '/admin/kitchen'
@@ -221,6 +233,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
BasketRoute: typeof BasketRoute
HelpRoute: typeof HelpRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
@@ -252,6 +265,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BasketRouteImport
parentRoute: typeof rootRouteImport
}
'/help': {
id: '/help'
path: '/help'
fullPath: '/help'
preLoaderRoute: typeof HelpRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -372,6 +392,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
BasketRoute: BasketRoute,
HelpRoute: HelpRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
+10
View File
@@ -32,3 +32,13 @@
align-items: center;
gap: $space-2;
}
.helpLink {
color: $color-text-muted;
text-decoration: none;
font-size: 0.9rem;
&:hover {
color: $color-text;
}
}
+3
View File
@@ -33,6 +33,9 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => {
<Logo name={brand.name} />
</Link>
<div className={styles.actions}>
<Link to="/help" className={styles.helpLink}>
Help
</Link>
<BasketLink />
<BetterAuthHeader />
</div>
+152
View File
@@ -0,0 +1,152 @@
@use '../styles/variables' as *;
.page {
max-width: 640px;
margin: 0 auto;
padding: $space-5 $space-3;
display: flex;
flex-direction: column;
gap: $space-5;
}
.heading {
margin: 0 0 $space-4;
}
.sectionHeading {
margin: 0 0 $space-3;
font-size: 1.1rem;
}
.orderContext {
margin-bottom: $space-4;
padding: $space-3;
border: 1px solid $color-primary;
border-radius: $radius;
background: color-mix(in srgb, $color-primary 8%, transparent);
.sectionHeading {
margin-bottom: $space-1;
}
}
.orderContextText {
margin: 0;
color: $color-text-muted;
}
.faq {
display: flex;
flex-direction: column;
gap: $space-2;
}
.faqItem {
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
overflow: hidden;
}
.faqQuestion {
display: flex;
align-items: center;
width: 100%;
padding: $space-3;
border: none;
background: none;
color: $color-text;
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.faqMarker {
position: relative;
flex-shrink: 0;
width: 1.2em;
height: 1.2em;
}
.faqMarkerGlyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $color-primary;
}
.faqAnswerWrapper {
overflow: hidden;
}
.faqAnswer {
margin: 0;
padding: 0 $space-3 $space-3 calc($space-3 + 1.2em);
color: $color-text-muted;
}
.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,
textarea {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
font-family: inherit;
color: $color-text;
background: $color-background;
}
textarea {
resize: vertical;
min-height: 6rem;
}
}
.submitButton {
align-self: flex-start;
padding: $space-2 $space-4;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.success {
color: $color-success;
font-size: 0.85rem;
margin: 0;
}
+215
View File
@@ -0,0 +1,215 @@
import { createFileRoute } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { z } from 'zod'
import { orderItemStatusValues } from '#/db/schema.ts'
import type { OrderItemStatus } from '#/db/schema.ts'
import { submitContactMessage } from '#/server/contact.ts'
import styles from './help.module.scss'
const orderStatusHelp: Record<OrderItemStatus, string> = {
placed: 'Your order has just been placed. If you need to change or cancel it, do it soon — once the kitchen starts cooking, we can’t make changes.',
paid: 'Your order is already being prepared, so we can’t change or cancel it now. Tell us what’s wrong below and we’ll help.',
ready: 'Your order is ready for pickup. If something’s off with it, let us know below.',
picked_up: 'This order has already been picked up. If something was wrong with it, let us know below.',
cancelled: 'This order was cancelled. If that wasn’t expected, let us know below.',
}
const faqs = [
{
question: 'How do I track my order?',
answer:
'Once you place an order you’ll land on its status page, which updates live as each step of your order is prepared. You can get back to it any time from the order confirmation link.',
},
{
question: 'Can I change or cancel an order after placing it?',
answer:
'Orders start preparing right away, so we can’t guarantee changes or cancellations once it’s placed. Reach out through the form below as soon as possible and we’ll do what we can.',
},
{
question: 'Do I need an account to order?',
answer:
'No — you can check out as a guest with just a name and a way to reach you. Creating an account just lets you see your order history in one place.',
},
{
question: 'What payment methods are accepted?',
answer: 'We accept all major debit and credit cards at checkout.',
},
{
question: 'How do I get a refund?',
answer:
'Send us a message with your order details using the contact form below and we’ll sort it out with you.',
},
]
type FaqItemProps = {
question: string
answer: string
}
const FaqItem = ({ question, answer }: FaqItemProps) => {
const reduceMotion = useReducedMotion()
const [open, setOpen] = useState(false)
const transition = { duration: reduceMotion ? 0 : 0.2, ease: 'easeOut' as const }
return (
<div className={styles.faqItem}>
<button
type="button"
className={styles.faqQuestion}
aria-expanded={open}
onClick={() => setOpen((isOpen) => !isOpen)}
>
<span className={styles.faqMarker} aria-hidden="true">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={open ? 'minus' : 'plus'}
className={styles.faqMarkerGlyph}
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
transition={transition}
>
{open ? '−' : '+'}
</motion.span>
</AnimatePresence>
</span>
{question}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="content"
className={styles.faqAnswerWrapper}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition}
>
<p className={styles.faqAnswer}>{answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
const HelpPage = () => {
const { orderId, item, status } = Route.useSearch()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState(() =>
orderId
? `Order #${orderId}${item ? ` — ${item}` : ''}\n\n`
: '',
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const submit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setError(null)
try {
await submitContactMessage({ data: { name, email, message } })
setSent(true)
setName('')
setEmail('')
setMessage('')
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Could not send your message. Please try again.',
)
} finally {
setSubmitting(false)
}
}
return (
<main className={styles.page}>
<div>
<h1 className={styles.heading}>Help</h1>
{status && (
<div className={styles.orderContext}>
<h2 className={styles.sectionHeading}>
About order #{orderId}
{item ? ` — ${item}` : ''}
</h2>
<p className={styles.orderContextText}>{orderStatusHelp[status]}</p>
</div>
)}
<h2 className={styles.sectionHeading}>Frequently asked questions</h2>
<div className={styles.faq}>
{faqs.map(({ question, answer }) => (
<FaqItem key={question} question={question} answer={answer} />
))}
</div>
</div>
<div>
<h2 className={styles.sectionHeading}>Still need help?</h2>
{sent ? (
<p className={styles.success}>
Thanks for reaching out — we’ll get back to you soon.
</p>
) : (
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Name
<input
type="text"
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}>
Message
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={submitting}
>
{submitting ? 'Sending…' : 'Send message'}
</button>
{error && <p className={styles.error}>{error}</p>}
</form>
)}
</div>
</main>
)
}
const searchSchema = z.object({
orderId: z.number().optional(),
item: z.string().optional(),
status: z.enum(orderItemStatusValues).optional(),
})
export const Route = createFileRoute('/help')({
validateSearch: searchSchema,
component: HelpPage,
})
+17
View File
@@ -0,0 +1,17 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '#/db/index.ts'
import { contactMessages } from '#/db/schema.ts'
export const submitContactMessage = createServerFn({ method: 'POST' })
.validator(
z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
}),
)
.handler(async ({ data }) => {
await db.insert(contactMessages).values(data)
})