383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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
|
||
// 0–3 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,
|
||
}),
|
||
})
|