335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
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,
|
|
})
|