All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
External lunch attendees had no way to pick their own dish — an admin had to set
it inline and no email was ever sent. (Marine added herself as an external
expecting a dish-selection link and never received one.)
Adds:
- ExternalAttendee.inviteSentAt + additive migration
- HMAC-signed external lunch token (mirrors finalist-token)
- Public no-login picker page /lunch/pick/[token] — dish + allergens + notes,
gated by the lunch change deadline, read-only after
- tRPC getExternalByToken / setExternalPick (public) + sendExternalInvite (admin)
- Auto-send invite on createExternal when an email is present; per-row resend
button + status chip (Invited / Picked / no email) in the logistics screen
- Unpicked externals chased by the lunch reminder cron + manual "Send reminders"
- sendExternalDishInviteEmail (branded). Page + email title use the configurable
venue ("Lunch at {venue}") rather than "grand finale"
Tests: token roundtrip/tamper/expiry, selectUnpickedExternals filter,
get/set-by-token happy + deadline + bad-token, createExternal auto-send,
cron external reminders. Full suite 303 passing; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, use, useEffect, useState } from 'react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Label } from '@/components/ui/label'
|
|
import { AlertCircle, CheckCircle2, Loader2, Salad, UtensilsCrossed } from 'lucide-react'
|
|
|
|
const ALLERGENS = [
|
|
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
|
|
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
|
|
] as const
|
|
type Allergen = (typeof ALLERGENS)[number]
|
|
|
|
interface PageProps {
|
|
params: Promise<{ token: string }>
|
|
}
|
|
|
|
function formatTag(t: string): string {
|
|
return t.replace('_', ' ').toLowerCase()
|
|
}
|
|
|
|
function formatWhen(d: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'long',
|
|
timeStyle: 'short',
|
|
}).format(d)
|
|
}
|
|
|
|
function CountdownLabel({ deadline }: { deadline: Date }) {
|
|
const [now, setNow] = useState<number>(Date.now())
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
return () => clearInterval(id)
|
|
}, [])
|
|
const ms = deadline.getTime() - now
|
|
if (ms <= 0) return <span className="text-destructive font-medium">closed</span>
|
|
const totalSec = Math.floor(ms / 1000)
|
|
const hours = Math.floor(totalSec / 3600)
|
|
const minutes = Math.floor((totalSec % 3600) / 60)
|
|
const seconds = totalSec % 60
|
|
if (hours >= 24) {
|
|
const days = Math.floor(hours / 24)
|
|
return (
|
|
<span className="font-medium tabular-nums">
|
|
{days}d {hours % 24}h remaining
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<span className="font-medium tabular-nums">
|
|
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
|
|
{seconds.toString().padStart(2, '0')} remaining
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function FriendlyError({ title, message }: { title: string; message: string }) {
|
|
return (
|
|
<Card className="mx-auto max-w-xl">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="text-muted-foreground h-5 w-5" />
|
|
<CardTitle>{title}</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">{message}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function DishPickContent({ token }: { token: string }) {
|
|
const { data, isLoading, error } = trpc.lunch.getExternalByToken.useQuery(
|
|
{ token },
|
|
{ retry: false },
|
|
)
|
|
const setPick = trpc.lunch.setExternalPick.useMutation()
|
|
|
|
const [dishId, setDishId] = useState<string>('')
|
|
const [allergens, setAllergens] = useState<Allergen[]>([])
|
|
const [allergenOther, setAllergenOther] = useState<string>('')
|
|
const [hydrated, setHydrated] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!hydrated && data) {
|
|
setDishId(data.external.dishId ?? '')
|
|
setAllergens((data.external.allergens as Allergen[]) ?? [])
|
|
setAllergenOther(data.external.allergenOther ?? '')
|
|
setHydrated(true)
|
|
}
|
|
}, [data, hydrated])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="mx-auto max-w-xl space-y-4">
|
|
<Skeleton className="h-8 w-2/3" />
|
|
<Skeleton className="h-32 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
const msg = error.message ?? ''
|
|
if (/expired/i.test(msg)) {
|
|
return (
|
|
<FriendlyError
|
|
title="This link has expired"
|
|
message="Please contact us at info@monaco-opc.com and we'll sort out your lunch."
|
|
/>
|
|
)
|
|
}
|
|
if (/signature|malformed|parseable/i.test(msg)) {
|
|
return (
|
|
<FriendlyError
|
|
title="This link is not valid"
|
|
message="Please check your email or contact us at info@monaco-opc.com."
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<FriendlyError
|
|
title="Something went wrong"
|
|
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
|
/>
|
|
)
|
|
}
|
|
if (!data) {
|
|
return (
|
|
<FriendlyError
|
|
title="Not found"
|
|
message="Please check your email link or contact us at info@monaco-opc.com."
|
|
/>
|
|
)
|
|
}
|
|
|
|
const deadline = data.changeDeadline ? new Date(data.changeDeadline) : null
|
|
const deadlinePassed = deadline ? new Date() > deadline : false
|
|
const eventAt = data.event.eventAt ? new Date(data.event.eventAt) : null
|
|
|
|
const handleSave = async () => {
|
|
setSubmitError(null)
|
|
try {
|
|
await setPick.mutateAsync({
|
|
token,
|
|
dishId: dishId || null,
|
|
allergens,
|
|
allergenOther: allergenOther.trim() || null,
|
|
})
|
|
setSaved(true)
|
|
} catch (err) {
|
|
setSubmitError(err instanceof Error ? err.message : 'Failed to save')
|
|
}
|
|
}
|
|
|
|
const eventCard = (
|
|
<Card className="border-primary/40 bg-primary/5">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<UtensilsCrossed className="text-primary h-5 w-5" />
|
|
<CardTitle>
|
|
{data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'}
|
|
</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1 text-sm">
|
|
<p>
|
|
Hi <strong>{data.external.name}</strong>, please choose your dish below.
|
|
</p>
|
|
{eventAt && (
|
|
<p className="text-muted-foreground">
|
|
<strong>When:</strong> {formatWhen(eventAt)}
|
|
</p>
|
|
)}
|
|
{data.event.notes && (
|
|
<p className="text-muted-foreground">{data.event.notes}</p>
|
|
)}
|
|
{deadline && !deadlinePassed && (
|
|
<p className="text-muted-foreground pt-1">
|
|
Choose by {formatWhen(deadline)} · <CountdownLabel deadline={deadline} />
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
|
|
// Past the change deadline → read-only.
|
|
if (deadlinePassed) {
|
|
const chosen = data.dishes.find((d) => d.id === data.external.dishId)
|
|
return (
|
|
<div className="mx-auto max-w-xl space-y-6">
|
|
{eventCard}
|
|
<FriendlyError
|
|
title="Dish selection is now closed"
|
|
message={
|
|
chosen
|
|
? `Your choice is "${chosen.name}". To change it, please contact us at info@monaco-opc.com.`
|
|
: 'The deadline to choose a dish has passed. Please contact us at info@monaco-opc.com.'
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-xl space-y-6">
|
|
{eventCard}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Salad className="h-4 w-4 text-emerald-600" /> Your dish
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<RadioGroup value={dishId} onValueChange={setDishId} className="gap-2">
|
|
{data.dishes.map((d) => (
|
|
<label
|
|
key={d.id}
|
|
htmlFor={`dish-${d.id}`}
|
|
className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
|
>
|
|
<RadioGroupItem id={`dish-${d.id}`} value={d.id} className="mt-0.5" />
|
|
<div>
|
|
<div className="font-medium">{d.name}</div>
|
|
{d.dietaryTags.length > 0 && (
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{d.dietaryTags.map((t) => (
|
|
<Badge key={t} variant="secondary" className="text-xs">
|
|
{formatTag(t)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
))}
|
|
{data.dishes.length === 0 && (
|
|
<p className="text-muted-foreground text-sm">
|
|
No dishes have been published yet. Please check back later.
|
|
</p>
|
|
)}
|
|
</RadioGroup>
|
|
|
|
<div>
|
|
<Label className="text-sm">Allergens</Label>
|
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{ALLERGENS.map((a) => (
|
|
<label key={a} className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
checked={allergens.includes(a)}
|
|
onCheckedChange={(v) =>
|
|
setAllergens(
|
|
v ? [...allergens, a] : allergens.filter((x) => x !== a),
|
|
)
|
|
}
|
|
/>
|
|
{formatTag(a)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-sm">Other allergens / dietary notes</Label>
|
|
<Textarea
|
|
value={allergenOther}
|
|
onChange={(e) => {
|
|
setAllergenOther(e.target.value)
|
|
setSaved(false)
|
|
}}
|
|
rows={2}
|
|
className="mt-1"
|
|
placeholder="e.g. severe nut allergy, no shellfish"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{submitError && (
|
|
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
|
{submitError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
{saved && !setPick.isPending ? (
|
|
<span className="flex items-center gap-2 text-sm text-emerald-600">
|
|
<CheckCircle2 className="h-4 w-4" /> Saved — you can change it until the deadline.
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<Button size="lg" onClick={handleSave} disabled={setPick.isPending}>
|
|
{setPick.isPending ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle2 className="mr-2 h-4 w-4" /> Save my choice
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function LunchPickPage({ params }: PageProps) {
|
|
const { token } = use(params)
|
|
return (
|
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
|
<DishPickContent token={token} />
|
|
</Suspense>
|
|
)
|
|
}
|