chore: seminarhof customer project
This commit is contained in:
402
apps/app/src/pages/availability.tsx
Normal file
402
apps/app/src/pages/availability.tsx
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user