chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-10 23:06:04 +02:00
commit 56df359d57
188 changed files with 20779 additions and 0 deletions

View 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,
})