chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-21 23:19:18 +02:00
commit dca4c76a13
161 changed files with 18517 additions and 0 deletions

View File

@@ -0,0 +1,382 @@
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,
}),
})