Files
seminarhof-e2e-mqock9p6/apps/app/src/pages/enquiry.tsx
2026-06-22 00:17:20 +02:00

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { m, mKey, mExists } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
ActionIcon,
Alert,
Box,
Button,
Checkbox,
Container,
Divider,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Tooltip,
} from '@pikku/mantine/core'
import { Info, Plus, Trash2 } from 'lucide-react'
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
type DateOption = { startDate: string; endDate: string }
const MAX_DATE_OPTIONS = 3
type State = {
eventName: string
eventOutline: string
description: string
// 03 prioritized date options, in priority order (first = preferred).
dateOptions: DateOption[]
expectedPersons: string
halfHouse: boolean
clientName: string
contactEmail: string
contactPhone: string
website: string
notes: string
agbAccepted: boolean
privacyAccepted: boolean
}
const empty = (search?: { date?: string; from?: string; to?: string }): State => {
const seedStart = search?.from ?? search?.date ?? ''
const seedEnd = search?.to ?? search?.date ?? ''
return {
eventName: '',
eventOutline: '',
description: '',
dateOptions: [{ startDate: seedStart, endDate: seedEnd }],
expectedPersons: '',
halfHouse: false,
clientName: '',
contactEmail: '',
contactPhone: '',
website: '',
notes: '',
agbAccepted: false,
privacyAccepted: false,
}
}
function EnquiryPage() {
useLocale()
const search = Route.useSearch()
const [form, setForm] = useState<State>(() => empty(search))
const [success, setSuccess] = useState<string | null>(null)
const mutation = usePikkuMutation('submitEnquiry', {
onSuccess: (data) => setSuccess(data.enquiryId),
})
const set = <K extends keyof State>(key: K, value: State[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const setOption = (i: number, patch: Partial<DateOption>) =>
setForm((f) => ({
...f,
dateOptions: f.dateOptions.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
}))
const addOption = () =>
setForm((f) =>
f.dateOptions.length >= MAX_DATE_OPTIONS
? f
: { ...f, dateOptions: [...f.dateOptions, { startDate: '', endDate: '' }] },
)
const removeOption = (i: number) =>
setForm((f) => ({ ...f, dateOptions: f.dateOptions.filter((_, idx) => idx !== i) }))
// A row is "complete" only when both dates are set and ordered. Partially
// filled or reversed rows block submission; fully empty form = no dates (ok).
const rowState = (o: DateOption): 'empty' | 'complete' | 'invalid' => {
if (!o.startDate && !o.endDate) return 'empty'
if (o.startDate && o.endDate && o.startDate <= o.endDate) return 'complete'
return 'invalid'
}
const optionsValid = form.dateOptions.every((o) => rowState(o) !== 'invalid')
const submit = () => {
const dateOptions = form.dateOptions
.filter((o) => rowState(o) === 'complete')
.map((o) => ({ startDate: o.startDate, endDate: o.endDate }))
mutation.mutate({
venueSlug: 'drawehn',
contact: {
name: form.clientName.trim() || undefined,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone || undefined,
website: form.website || undefined,
},
event: {
eventName: form.eventName,
eventOutline: form.eventOutline || undefined,
description: form.description || undefined,
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
halfHouse: form.halfHouse,
},
dateOptions: dateOptions.length ? dateOptions : undefined,
notes: form.notes || undefined,
privacyAccepted: form.privacyAccepted as true,
})
}
if (success) {
return (
<MarketingShell>
<Container size="sm" py="xl">
<Alert color="green" title={m.common_pages_enquiry_success_title()}>
{m.common_pages_enquiry_success_body({ enquiryId: success })}
</Alert>
</Container>
</MarketingShell>
)
}
const canSubmit =
form.eventName.trim() &&
form.contactEmail.trim() &&
form.agbAccepted &&
form.privacyAccepted &&
optionsValid
return (
<MarketingShell>
<Container size="md" py="xl">
<Stack gap="lg">
<PageTitle title={m.common_pages_enquiry_title()} />
<Paper withBorder radius="md" p="lg">
<Stack gap="lg">
<Section title={m.common_pages_enquiry_sections_event()}>
<TextInput
data-testid="courseName"
label={<FieldLabel label={m.common_pages_enquiry_fields_event_name()} hint="eventName" />}
required
value={form.eventName}
onChange={(e) => set('eventName', e.currentTarget.value)}
/>
<TextInput
data-testid="eventOutline"
label={<FieldLabel label={m.common_pages_enquiry_fields_event_outline()} hint="eventOutline" />}
value={form.eventOutline}
onChange={(e) => set('eventOutline', e.currentTarget.value)}
/>
<Textarea
data-testid="description"
label={<FieldLabel label={m.common_pages_enquiry_fields_description()} hint="description" />}
value={form.description}
onChange={(e) => set('description', e.currentTarget.value)}
autosize
minRows={4}
/>
<Stack gap="xs">
<FieldLabel label={m.common_pages_enquiry_fields_date_options()} hint="dateOptions" />
{form.dateOptions.length === 0 && (
<Text size="sm" c="dimmed">{m.common_pages_enquiry_date_options_none()}</Text>
)}
{form.dateOptions.map((opt, i) => {
const invalid = rowState(opt) === 'invalid'
return (
<Group key={i} gap="xs" align="flex-end" wrap="nowrap" data-testid={`date-option-${i}`}>
<TextInput
data-testid={i === 0 ? 'startDate' : `date-option-${i}-start`}
type="date"
flex={1}
label={i === 0 ? m.common_pages_enquiry_fields_start_date() : undefined}
value={opt.startDate}
onChange={(e) => setOption(i, { startDate: e.currentTarget.value })}
error={invalid ? m.common_pages_enquiry_date_options_invalid() : undefined}
/>
<TextInput
data-testid={i === 0 ? 'endDate' : `date-option-${i}-end`}
type="date"
flex={1}
label={i === 0 ? m.common_pages_enquiry_fields_end_date() : undefined}
value={opt.endDate}
onChange={(e) => setOption(i, { endDate: e.currentTarget.value })}
/>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={m.common_pages_enquiry_date_options_remove()}
data-testid={`date-option-${i}-remove`}
onClick={() => removeOption(i)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
)
})}
{form.dateOptions.length < MAX_DATE_OPTIONS && (
<Group>
<Button
variant="light"
color="plum"
size="xs"
leftSection={<Plus size={14} />}
onClick={addOption}
data-testid="add-date-option"
>
{m.common_pages_enquiry_date_options_add()}
</Button>
</Group>
)}
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<NumberInput
label={<FieldLabel label={m.common_pages_enquiry_fields_expected_persons()} hint="expectedPersons" />}
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
min={1}
/>
<Select
label={<FieldLabel label={m.common_pages_enquiry_fields_half_house()} hint="halfHouse" />}
value={form.halfHouse ? 'true' : 'false'}
onChange={(v) => set('halfHouse', v === 'true')}
allowDeselect={false}
data={[
{ value: 'false', label: m.common_pages_enquiry_fields_half_house_no() },
{ value: 'true', label: m.common_pages_enquiry_fields_half_house_yes() },
]}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_client()}>
<TextInput
data-testid="organisationName"
label={<FieldLabel label={m.common_pages_enquiry_fields_client_name()} hint="clientName" />}
value={form.clientName}
onChange={(e) => set('clientName', e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
data-testid="contactEmail"
type="email"
label={<FieldLabel label={m.common_pages_enquiry_fields_contact_email()} hint="contactEmail" />}
required
value={form.contactEmail}
onChange={(e) => set('contactEmail', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_contact_phone()} hint="contactPhone" />}
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.currentTarget.value)}
/>
<TextInput
label={<FieldLabel label={m.common_pages_enquiry_fields_website()} hint="website" />}
value={form.website}
onChange={(e) => set('website', e.currentTarget.value)}
/>
</SimpleGrid>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_notes()}>
<Textarea
label={<FieldLabel label={m.common_pages_enquiry_fields_notes()} hint="notes" />}
value={form.notes}
onChange={(e) => set('notes', e.currentTarget.value)}
autosize
minRows={3}
/>
</Section>
<Divider />
<Section title={m.common_pages_enquiry_sections_terms()}>
<Checkbox
data-testid="agbAccepted"
label={m.common_pages_enquiry_agb_accept()}
checked={form.agbAccepted}
onChange={(e) => set('agbAccepted', e.currentTarget.checked)}
/>
<Checkbox
data-testid="privacyAccepted"
label={<FieldLabel label={m.common_pages_enquiry_fields_privacy_accepted()} hint="privacyAccepted" />}
checked={form.privacyAccepted}
onChange={(e) => set('privacyAccepted', e.currentTarget.checked)}
/>
</Section>
{mutation.error && (
<Alert color="red">{m.common_pages_enquiry_error()}</Alert>
)}
<Group justify="flex-end">
<Button
onClick={submit}
disabled={!canSubmit || mutation.isPending}
loading={mutation.isPending}
>
{m.common_pages_enquiry_submit()}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
</MarketingShell>
)
}
/**
* Field label with an optional tooltip. The icon only renders when a hint
* string exists at `pages.enquiry.hints.<hint>` — so adding/removing a hint key
* in the locale files is all it takes to show or hide the tooltip per field.
*/
function FieldLabel({ label, hint }: { label: string; hint: string }) {
useLocale()
const key = `common.pages.enquiry.hints.${hint}`
if (!mExists(key)) return <>{label}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{label}
<Tooltip
label={mKey(key as any)}
multiline
w={240}
withArrow
events={{ hover: true, focus: true, touch: true }}
>
<Info
size={14}
aria-label={mKey(key as any)}
tabIndex={0}
style={{ cursor: 'help', opacity: 0.55, flexShrink: 0 }}
/>
</Tooltip>
</span>
)
}
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('/enquiry')({
component: EnquiryPage,
validateSearch: (s: Record<string, unknown>): { date?: string; from?: string; to?: string } => ({
date: typeof s.date === 'string' ? s.date : undefined,
from: typeof s.from === 'string' ? s.from : undefined,
to: typeof s.to === 'string' ? s.to : undefined,
}),
})