403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
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,
|
||
})
|