chore: seminarhof customer project
This commit is contained in:
334
apps/app/src/pages/booking-form.$token.tsx
Normal file
334
apps/app/src/pages/booking-form.$token.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Radio,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { PageTitle } from '../components/AppLayout'
|
||||
import { fmtDate } from '../lib/dates'
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
billingAddress: string
|
||||
contactPhone: string
|
||||
website: string
|
||||
paymentMethod: 'cash' | 'transfer'
|
||||
expectedPersons: string
|
||||
arrivalTime: string
|
||||
departureTime: string
|
||||
dailyPlan: string
|
||||
onlineAd: boolean
|
||||
notes: string
|
||||
acceptTerms: boolean
|
||||
acceptPrivacy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Public landing for the passwordless booking-form magic link. Consumes the
|
||||
* token (which issues a client session cookie), then renders the binding form.
|
||||
* The token IS the auth — this route is intentionally not behind RequireAuth.
|
||||
*/
|
||||
function BookingFormLanding() {
|
||||
const { token } = Route.useParams()
|
||||
useLocale()
|
||||
const qc = useQueryClient()
|
||||
const [bookingId, setBookingId] = useState<string | null>(null)
|
||||
// Outcome is tracked in component state (not the mutation observer) so it
|
||||
// survives StrictMode's mount→unmount→mount and the consumedRef guard below.
|
||||
const [phase, setPhase] = useState<'pending' | 'ready' | 'error'>('pending')
|
||||
const consumedRef = useRef(false)
|
||||
|
||||
const consume = usePikkuMutation('consumeBookingFormLink', {
|
||||
onSuccess: async (data) => {
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
setBookingId(data.bookingId)
|
||||
setPhase('ready')
|
||||
},
|
||||
onError: () => setPhase('error'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (consumedRef.current) return
|
||||
consumedRef.current = true
|
||||
consume.mutate({ token })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Alert color="red" title={m.common__pages__booking_form__link_invalid__title()}>
|
||||
{m.common__pages__booking_form__link_invalid__body()}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (!bookingId) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="plum" />
|
||||
<Text c="dimmed">{m.common__states__loading()}</Text>
|
||||
</Group>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return <BookingFormInner bookingId={bookingId} />
|
||||
}
|
||||
|
||||
function BookingFormInner({ bookingId }: { bookingId: string }) {
|
||||
useLocale()
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getBookingFormData', { bookingId })
|
||||
|
||||
const submit = usePikkuMutation('submitBookingForm', {
|
||||
onSuccess: () => setSuccess(true),
|
||||
})
|
||||
|
||||
// Seed editable state once the prefill data arrives.
|
||||
useEffect(() => {
|
||||
if (data && !form) {
|
||||
setForm({
|
||||
name: data.client.name ?? '',
|
||||
billingAddress: data.client.billingAddress ?? '',
|
||||
contactPhone: data.client.contactPhone ?? '',
|
||||
website: data.client.website ?? '',
|
||||
paymentMethod: (data.paymentMethod as 'cash' | 'transfer') ?? 'cash',
|
||||
expectedPersons: data.expectedPersons != null ? String(data.expectedPersons) : '',
|
||||
arrivalTime: data.arrivalTime ?? '',
|
||||
departureTime: data.departureTime ?? '',
|
||||
dailyPlan: data.dailyPlan ?? '',
|
||||
onlineAd: data.onlineAd,
|
||||
notes: data.notes ?? '',
|
||||
acceptTerms: false,
|
||||
acceptPrivacy: false,
|
||||
})
|
||||
}
|
||||
}, [data, form])
|
||||
|
||||
if (isLoading || !form) {
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Alert color="red">{m.common__states__error()}</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="plum" />
|
||||
</Group>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((f) => (f ? { ...f, [key]: value } : f))
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Alert color="green" title={m.common__pages__booking_form__success__title()}>
|
||||
{m.common__pages__booking_form__success__body()}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
form.name.trim() &&
|
||||
form.billingAddress.trim() &&
|
||||
form.acceptTerms &&
|
||||
form.acceptPrivacy
|
||||
|
||||
const submitForm = () => {
|
||||
submit.mutate({
|
||||
bookingId,
|
||||
name: form.name.trim(),
|
||||
billingAddress: form.billingAddress.trim(),
|
||||
contactPhone: form.contactPhone || undefined,
|
||||
website: form.website || undefined,
|
||||
paymentMethod: form.paymentMethod,
|
||||
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
|
||||
arrivalTime: form.arrivalTime || undefined,
|
||||
departureTime: form.departureTime || undefined,
|
||||
dailyPlan: form.dailyPlan || undefined,
|
||||
onlineAd: form.onlineAd,
|
||||
notes: form.notes || undefined,
|
||||
acceptTerms: form.acceptTerms,
|
||||
acceptPrivacy: form.acceptPrivacy,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Stack gap="lg">
|
||||
<Box>
|
||||
<Title order={2}>{m.common__pages__booking_form__heading()}</Title>
|
||||
<Text c="dimmed" mt={4}>
|
||||
{asI18n(
|
||||
`${data!.eventName}${data!.startDate ? ` · ${fmtDate(new Date(data!.startDate), getLocale())}` : ''}${data!.endDate ? ` → ${fmtDate(new Date(data!.endDate), getLocale())}` : ''}`,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="lg">
|
||||
<Section title={m.common__pages__booking_form__sections__billing()}>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__name()}
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__billing_address()}
|
||||
required
|
||||
autosize
|
||||
minRows={3}
|
||||
value={form.billingAddress}
|
||||
onChange={(e) => set('billingAddress', e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__contact_phone()}
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__website()}
|
||||
value={form.website}
|
||||
onChange={(e) => set('website', e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Radio.Group
|
||||
label={m.common__pages__booking_form__fields__payment_method()}
|
||||
value={form.paymentMethod}
|
||||
onChange={(v) => set('paymentMethod', v as 'cash' | 'transfer')}
|
||||
required
|
||||
>
|
||||
<Group mt="xs" gap="lg">
|
||||
<Radio value="cash" label={m.common__pages__booking_form__payment__cash()} />
|
||||
<Radio value="transfer" label={m.common__pages__booking_form__payment__transfer()} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</Section>
|
||||
|
||||
<Divider />
|
||||
<Section title={m.common__pages__booking_form__sections__event()}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<NumberInput
|
||||
label={m.common__pages__booking_form__fields__expected_persons()}
|
||||
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
|
||||
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
|
||||
min={1}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__arrival_time()}
|
||||
value={form.arrivalTime}
|
||||
onChange={(e) => set('arrivalTime', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__departure_time()}
|
||||
value={form.departureTime}
|
||||
onChange={(e) => set('departureTime', e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__daily_plan()}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.dailyPlan}
|
||||
onChange={(e) => set('dailyPlan', e.currentTarget.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__online_ad()}
|
||||
checked={form.onlineAd}
|
||||
onChange={(e) => set('onlineAd', e.currentTarget.checked)}
|
||||
/>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__notes()}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.currentTarget.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Divider />
|
||||
<Section title={m.common__pages__booking_form__sections__terms()}>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__accept_terms()}
|
||||
checked={form.acceptTerms}
|
||||
onChange={(e) => set('acceptTerms', e.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__accept_privacy()}
|
||||
checked={form.acceptPrivacy}
|
||||
onChange={(e) => set('acceptPrivacy', e.currentTarget.checked)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{submit.error && <Alert color="red">{m.common__pages__booking_form__error()}</Alert>}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="plum"
|
||||
onClick={submitForm}
|
||||
disabled={!canSubmit || submit.isPending}
|
||||
loading={submit.isPending}
|
||||
>
|
||||
{m.common__pages__booking_form__submit()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
{asI18n(`${title}`)}
|
||||
</Text>
|
||||
<Box>
|
||||
<Stack gap="md">{children}</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/booking-form/$token')({
|
||||
component: BookingFormLanding,
|
||||
})
|
||||
Reference in New Issue
Block a user