chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-11 07:59:25 +02:00
commit 465dd7a41a
188 changed files with 20779 additions and 0 deletions

View File

@@ -0,0 +1,402 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
// Brand-aligned: free is the inviting state (cream + plum text, clickable),
// confirmed is fully booked (filled plum), enquiry/reserved sit between.
const STATUS_BG: Record<string, { bg: string; fg: string }> = {
free: { bg: '#FBF8FA', fg: '#4E3149' },
enquiry: { bg: '#FFF8E0', fg: '#806600' },
reserved: { bg: '#FFEDDC', fg: '#8A4A1A' },
confirmed: { bg: '#88557F', fg: '#FFFFFF' },
}
const STATUS_KEYS = ['free', 'enquiry', 'reserved', 'confirmed'] as const
type DayBooking = {
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
}
type DayCell = {
date: Date
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
bookings: DayBooking[]
}
// Berlin-local YYYY-MM-DD string from a Date
const berlinStr = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
function groupByMonth(days: DayCell[]) {
const months: Record<string, DayCell[]> = {}
for (const d of days) {
const key = berlinStr(d.date).slice(0, 7)
;(months[key] ??= []).push(d)
}
return Object.entries(months).map(([key, ds]) => ({ key, days: ds }))
}
function monthLabel(yyyymm: string, locale: string) {
const [y, m] = yyyymm.split('-')
const d = new Date(Number(y), Number(m) - 1, 1)
return d.toLocaleString(locale, { month: 'long', year: 'numeric' })
}
function dowMonStart(d: Date) {
return (d.getUTCDay() + 6) % 7
}
// Inclusive list of YYYY-MM-DD strings between two date strings (UTC day steps).
function rangeDates(start: string, end: string): string[] {
const out: string[] = []
const cur = new Date(`${start}T00:00:00Z`)
const last = new Date(`${end}T00:00:00Z`)
while (cur <= last) {
out.push(cur.toISOString().slice(0, 10))
cur.setUTCDate(cur.getUTCDate() + 1)
}
return out
}
const SELECT_BG = { bg: '#88557F', fg: '#FFFFFF' } // endpoint
const SELECT_MID = { bg: '#EFE3ED', fg: '#4E3149' } // in-between
function MonthGrid({
days,
dowLabels,
statusLabels,
halfHouseLabel,
selected,
endpoints,
onDayClick,
}: {
days: DayCell[]
dowLabels: string[]
statusLabels: Record<string, string>
halfHouseLabel: string
selected: Set<string>
endpoints: Set<string>
onDayClick: (dateStr: string) => void
}) {
const first = days[0]
if (!first) return null
const leadingBlanks = dowMonStart(first.date)
return (
<Box>
<SimpleGrid cols={7} spacing={4}>
{dowLabels.map((d, i) => (
<Text key={i} size="xs" c="dimmed" ta="center">{asI18n(`${d}`)}</Text>
))}
{Array.from({ length: leadingBlanks }).map((_, i) => (
<Box key={`blank-${i}`} />
))}
{days.map((d) => {
const dateStr = berlinStr(d.date)
const isEndpoint = endpoints.has(dateStr)
const isSelected = selected.has(dateStr)
const style = isEndpoint ? SELECT_BG : isSelected ? SELECT_MID : STATUS_BG[d.status]
const dayNum = Number(dateStr.slice(8, 10))
// Two half-house bookings sharing a day → split the cell with a
// diagonal line (top-left = first booking, bottom-right = second).
// 3+ half-house bookings on one day (data error) intentionally fall
// back to the single collapsed colour — don't "fix" that into an
// N-way split without a real requirement.
const isSplit =
!isEndpoint && !isSelected &&
d.bookings.length === 2 && d.bookings.every((b) => b.halfHouse)
const splitBg = isSplit
? `linear-gradient(135deg, ${STATUS_BG[d.bookings[0].status].bg} 0 49%, #FFFFFF 49% 51%, ${STATUS_BG[d.bookings[1].status].bg} 51% 100%)`
: undefined
const bookingLabel = (b: DayBooking) =>
`${b.eventName ?? statusLabels[b.status]}${b.halfHouse ? ` (${halfHouseLabel})` : ''}`
const tooltipLabel = isSplit ? (
<Stack gap={0}>
<Text size="xs" fw={600}>{asI18n(`${dateStr}`)}</Text>
{d.bookings.map((b, i) => (
<Text key={i} size="xs">{asI18n(`${bookingLabel(b)}`)}</Text>
))}
</Stack>
) : d.eventName
? asI18n(`${dateStr}${d.eventName}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
: asI18n(`${dateStr}${statusLabels[d.status]}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
return (
<Tooltip key={dateStr} label={tooltipLabel} withArrow>
<Box
style={{
background: splitBg ?? style.bg,
color: isSplit ? '#4E3149' : style.fg,
borderRadius: 4,
textAlign: 'center',
padding: '6px 0',
fontSize: 13,
fontWeight: isEndpoint ? 700 : undefined,
boxShadow: isSelected && !isEndpoint ? 'inset 0 0 0 1px #88557F' : undefined,
cursor: d.status === 'free' ? 'pointer' : 'default',
position: 'relative',
}}
onClick={() => {
if (d.status === 'free') onDayClick(dateStr)
}}
>
<span style={isSplit ? { textShadow: '0 0 3px rgba(255,255,255,0.95)' } : undefined}>
{dayNum}
</span>
{/* Single half-house booking keeps the corner dot; a split cell
already communicates the half-house state visually. */}
{d.halfHouse && !isSplit && (
<Box
style={{
position: 'absolute',
top: 2,
right: 4,
width: 6,
height: 6,
borderRadius: 3,
background: 'currentColor',
opacity: 0.5,
}}
/>
)}
</Box>
</Tooltip>
)
})}
</SimpleGrid>
</Box>
)
}
const THIS_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [THIS_YEAR, THIS_YEAR + 1, THIS_YEAR + 2].map((y) => ({
value: String(y),
label: String(y),
}))
function AvailabilityPage() {
useLocale()
const navigate = useNavigate()
const [year, setYear] = useState(THIS_YEAR)
// Two-click range selection: first free day sets `start`, second sets `end`.
const [sel, setSel] = useState<{ start: string; end: string | null } | null>(null)
const from = `${year}-01-01`
const to = `${year}-12-31`
const { data, isLoading, error } = usePikkuQuery('getAvailability', { venueSlug: 'drawehn', from, to })
// Lookup of day status by Berlin-local date string, for validating ranges.
const statusByDate = useMemo(() => {
const m = new Map<string, DayCell['status']>()
for (const d of (data?.days as DayCell[] | undefined) ?? []) {
m.set(berlinStr(d.date), d.status)
}
return m
}, [data])
const handleDayClick = (ds: string) => {
setSel((prev) => {
// No selection yet, or a completed range → start fresh.
if (!prev || prev.end) return { start: ds, end: null }
// Re-click the start → deselect.
if (ds === prev.start) return null
// Earlier than start → move the start.
if (ds < prev.start) return { start: ds, end: null }
// Later than start → close the range only if every day in it is free.
const allFree = rangeDates(prev.start, ds).every((x) => statusByDate.get(x) === 'free')
return allFree ? { start: prev.start, end: ds } : { start: ds, end: null }
})
}
const selectedDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set(rangeDates(sel.start, sel.end ?? sel.start))
}, [sel])
const endpointDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set([sel.start, sel.end ?? sel.start])
}, [sel])
const months = useMemo(() => (data ? groupByMonth(data.days as DayCell[]) : []), [data])
const stats = useMemo(() => {
if (!data) return { free: 0, total: 0 }
const total = data.days.length
const free = data.days.filter((d) => d.status === 'free').length
return { free, total }
}, [data])
// Localised mon-start day-of-week labels.
const dowLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' })
// 2024-01-01 was a Monday — convenient anchor.
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i))))
}, [getLocale()])
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common__states__loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container></MarketingShell>
if (!data) return null
const statusLabels: Record<string, string> = {
free: m.common__pages__availability__status__free(),
enquiry: m.common__pages__availability__status__enquiry(),
reserved: m.common__pages__availability__status__reserved(),
confirmed: m.common__pages__availability__status__confirmed(),
}
const halfHouseLabel = m.common__pages__availability__status__half_house()
// `enquiry` is only surfaced to staff (the backend hides it from anonymous
// viewers), so only show its legend swatch when such a day is actually
// present in the data.
const hasEnquiry = (data.days as DayCell[]).some((d) => d.status === 'enquiry')
const legendKeys = hasEnquiry ? STATUS_KEYS : STATUS_KEYS.filter((k) => k !== 'enquiry')
const fmtDate = (ds: string) =>
new Date(`${ds}T00:00:00Z`).toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
const selStart = sel?.start ?? null
const selEnd = sel?.end ?? null
const rangeLabel = selStart
? selEnd && selEnd !== selStart
? `${fmtDate(selStart)} ${fmtDate(selEnd)}`
: fmtDate(selStart)
: ''
const createEnquiry = () => {
if (!selStart) return
navigate({ to: '/enquiry', search: { from: selStart, to: selEnd ?? selStart } })
}
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common__pages__availability__title()} />
<Group align="flex-end" gap="md">
<Select
label={m.common__pages__availability__year_label()}
data={YEAR_OPTIONS}
value={String(year)}
onChange={(v) => {
if (v) {
setYear(Number(v))
setSel(null)
}
}}
allowDeselect={false}
w={120}
/>
<Text size="sm" pb={6}>
{m.common__pages__availability__free_stat({
free: stats.free,
total: stats.total,
percent: Math.round((stats.free / stats.total) * 100),
})}
</Text>
</Group>
<Group gap="md">
{legendKeys.map((key) => (
<Group key={key} gap={6} align="center">
<Box w={14} h={14} style={{ background: STATUS_BG[key].bg, borderRadius: 3 }} />
<Text size="xs">{asI18n(`${statusLabels[key]}`)}</Text>
</Group>
))}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{months.map((month) => (
<Paper key={month.key} withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4} tt="capitalize">{asI18n(`${monthLabel(month.key, getLocale())}`)}</Title>
<Badge variant="light">
{m.common__pages__availability__free_month_badge({
count: month.days.filter((d) => d.status === 'free').length,
})}
</Badge>
</Group>
<MonthGrid
days={month.days}
dowLabels={dowLabels}
statusLabels={statusLabels}
halfHouseLabel={halfHouseLabel}
selected={selectedDates}
endpoints={endpointDates}
onDayClick={handleDayClick}
/>
</Stack>
</Paper>
))}
</SimpleGrid>
{selStart && (
<Paper
withBorder
shadow="md"
radius="md"
p="md"
pos="sticky"
bottom={16}
style={{ zIndex: 2 }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Stack gap={2}>
<Text fw={600}>
{m.common__pages__availability__selected_range({ range: rangeLabel })}
</Text>
{!selEnd && (
<Text size="xs" c="dimmed">
{m.common__pages__availability__select_end_hint()}
</Text>
)}
</Stack>
<Group gap="xs">
<Button variant="subtle" color="gray" onClick={() => setSel(null)}>
{m.common__pages__availability__clear_selection()}
</Button>
<Button data-testid="create-enquiry" onClick={createEnquiry}>
{m.common__pages__availability__create_enquiry()}
</Button>
</Group>
</Group>
</Paper>
)}
<Text size="xs" c="dimmed" ta="center">
{m.common__pages__availability__footnote()}
</Text>
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/availability')({
component: AvailabilityPage,
})