chore: seminarhof customer project
This commit is contained in:
14
apps/app/src/auth.config.ts
Normal file
14
apps/app/src/auth.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { auth0 } from './auth/providers/auth0'
|
||||
import type { AuthProvider } from './auth/providers/_types'
|
||||
|
||||
/**
|
||||
* Active auth providers for this app.
|
||||
*
|
||||
* Providers are copied from `src/auth/providers/*.ts.inactive` into the
|
||||
* active set — the Fabric console's auth browser runs this as a plan.
|
||||
* Each provider's backend handler reads its secrets via
|
||||
* `secrets.getSecret()` (never from `process.env` on the client).
|
||||
*/
|
||||
export const activeProviders: AuthProvider[] = [auth0]
|
||||
|
||||
export const primaryProvider = activeProviders[0]
|
||||
47
apps/app/src/auth.tsx
Normal file
47
apps/app/src/auth.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { Navigate } from '@tanstack/react-router'
|
||||
import { Container, Text } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
|
||||
type Me = ReturnType<typeof usePikkuQuery<'me'>>['data']
|
||||
|
||||
const AuthContext = createContext<Me | null>(null)
|
||||
|
||||
export function useAuth(): NonNullable<Me> {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used inside <RequireAuth>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function RequireAuth({
|
||||
roles,
|
||||
children,
|
||||
}: {
|
||||
roles?: Array<'client' | 'admin' | 'owner'>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useLocale()
|
||||
const { data, isLoading, error } = usePikkuQuery('me', null, {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
}
|
||||
if (error || !data) {
|
||||
return <Navigate to="/login" />
|
||||
}
|
||||
if (roles && !roles.includes(data.role as 'client' | 'admin' | 'owner')) {
|
||||
return <Navigate to="/" />
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={data}>
|
||||
<AppLayout user={data}>{children}</AppLayout>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
20
apps/app/src/auth/providers/_types.ts
Normal file
20
apps/app/src/auth/providers/_types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* Shape every auth provider file exports.
|
||||
*
|
||||
* Providers are shadcn-style: copy from `providers/*.ts.inactive` into an
|
||||
* active `.ts` file and register in `auth.config.ts`. Backend OAuth routes
|
||||
* live in `@project/functions/src/auth/*`.
|
||||
*/
|
||||
export interface AuthProvider {
|
||||
id: 'auth0' | 'email' | 'github' | 'google' | 'microsoft' | 'passwordless'
|
||||
labelKey: string
|
||||
Icon: ComponentType<{ size?: number | string }>
|
||||
/** Variant tone used for provider button treatment. */
|
||||
tone: 'primary' | 'secondary' | 'default'
|
||||
/** Required env var names — surfaced by the console's auth browser. */
|
||||
envVars: string[]
|
||||
/** Kicks off the login flow. May redirect (OAuth) or open a modal (email). */
|
||||
login(): Promise<void> | void
|
||||
}
|
||||
22
apps/app/src/auth/providers/auth0.ts
Normal file
22
apps/app/src/auth/providers/auth0.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Lock } from 'lucide-react'
|
||||
import type { AuthProvider } from './_types'
|
||||
|
||||
/**
|
||||
* Auth0 — default provider. Universal login gives email + password out of
|
||||
* the box; social providers (GitHub / Google / etc.) configured on the
|
||||
* Auth0 tenant side, not in this file.
|
||||
*
|
||||
* Secrets (`AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`) are
|
||||
* read by the backend via `secrets.getSecret()`. This client file never
|
||||
* sees them.
|
||||
*/
|
||||
export const auth0: AuthProvider = {
|
||||
id: 'auth0',
|
||||
labelKey: 'auth__providers__auth0',
|
||||
Icon: Lock,
|
||||
tone: 'primary',
|
||||
envVars: ['AUTH0_DOMAIN', 'AUTH0_CLIENT_ID', 'AUTH0_CLIENT_SECRET'],
|
||||
login: () => {
|
||||
window.location.href = '/auth/auth0/login'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/github.ts.inactive
Normal file
13
apps/app/src/auth/providers/github.ts.inactive
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Github } from 'lucide-react'
|
||||
import type { AuthProvider } from './_types'
|
||||
|
||||
export const github: AuthProvider = {
|
||||
id: 'github',
|
||||
labelKey: 'auth:providers.github',
|
||||
Icon: Github,
|
||||
tone: 'default',
|
||||
envVars: ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'],
|
||||
login: () => {
|
||||
window.location.href = '/auth/github/login'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/google.ts.inactive
Normal file
13
apps/app/src/auth/providers/google.ts.inactive
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Chrome } from 'lucide-react'
|
||||
import type { AuthProvider } from './_types'
|
||||
|
||||
export const google: AuthProvider = {
|
||||
id: 'google',
|
||||
labelKey: 'auth:providers.google',
|
||||
Icon: Chrome,
|
||||
tone: 'default',
|
||||
envVars: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'],
|
||||
login: () => {
|
||||
window.location.href = '/auth/google/login'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/microsoft.ts.inactive
Normal file
13
apps/app/src/auth/providers/microsoft.ts.inactive
Normal file
@@ -0,0 +1,13 @@
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import type { AuthProvider } from './_types'
|
||||
|
||||
export const microsoft: AuthProvider = {
|
||||
id: 'microsoft',
|
||||
labelKey: 'auth:providers.microsoft',
|
||||
Icon: KeyRound,
|
||||
tone: 'default',
|
||||
envVars: ['MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'MS_TENANT_ID'],
|
||||
login: () => {
|
||||
window.location.href = '/auth/microsoft/login'
|
||||
},
|
||||
}
|
||||
13
apps/app/src/auth/providers/passwordless.ts.inactive
Normal file
13
apps/app/src/auth/providers/passwordless.ts.inactive
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Fingerprint } from 'lucide-react'
|
||||
import type { AuthProvider } from './_types'
|
||||
|
||||
export const passwordless: AuthProvider = {
|
||||
id: 'passwordless',
|
||||
labelKey: 'auth:providers.passwordless',
|
||||
Icon: Fingerprint,
|
||||
tone: 'default',
|
||||
envVars: ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'],
|
||||
login: () => {
|
||||
window.location.href = '/auth/passwordless/request'
|
||||
},
|
||||
}
|
||||
271
apps/app/src/components/AppLayout.tsx
Normal file
271
apps/app/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Avatar,
|
||||
AppShell,
|
||||
Burger,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { role as roleLabel, type I18nMessage } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { signOut } from '../lib/auth'
|
||||
import { BrandMark } from './Brand'
|
||||
|
||||
/**
|
||||
* Per-page title shown in the app header next to the brand mark. Pages opt in
|
||||
* via `usePageTitle(...)`; pages that don't call it leave the header showing
|
||||
* only the brand, so existing pages keep working unchanged.
|
||||
*/
|
||||
const PageTitleContext = createContext<
|
||||
((title: string | undefined) => void) | null
|
||||
>(null)
|
||||
|
||||
export function PageTitle({ title }: { title: string }) {
|
||||
usePageTitle(title)
|
||||
return null
|
||||
}
|
||||
|
||||
export function usePageTitle(title: string | undefined) {
|
||||
const setTitle = useContext(PageTitleContext)
|
||||
useEffect(() => {
|
||||
// No-op safely if used outside the provider (e.g. unauthenticated pages).
|
||||
if (!setTitle) return
|
||||
setTitle(title)
|
||||
return () => setTitle(undefined)
|
||||
}, [setTitle, title])
|
||||
}
|
||||
|
||||
type Role = 'client' | 'admin' | 'owner'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
label: I18nMessage
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
type Section = {
|
||||
title: I18nMessage
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
title: m.common__nav__sections__workspace,
|
||||
items: [
|
||||
{ to: '/admin', label: m.common__nav__items__dashboard, roles: ['admin', 'owner'] },
|
||||
{ to: '/admin/bookings', label: m.common__nav__items__bookings, roles: ['admin', 'owner'] },
|
||||
{ to: '/admin/enquiries', label: m.common__nav__items__enquiries, roles: ['admin', 'owner'] },
|
||||
{ to: '/kanban', label: m.common__nav__items__kanban, roles: ['admin', 'owner'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: m.common__nav__sections__public,
|
||||
items: [
|
||||
{ to: '/availability', label: m.common__nav__items__availability, roles: ['client', 'admin', 'owner'] },
|
||||
{ to: '/enquiry', label: m.common__nav__items__enquiry, roles: ['admin', 'owner'] },
|
||||
{ to: '/events', label: m.common__nav__items__events, roles: ['client', 'admin', 'owner'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
export function AppLayout({
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
user: { name: string; role: string; email: string }
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useLocale()
|
||||
const [opened, { toggle, close }] = useDisclosure()
|
||||
const [pageTitle, setPageTitle] = useState<string | undefined>(undefined)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const role = user.role as Role
|
||||
|
||||
const [logoutPending, setLogoutPending] = useState(false)
|
||||
const handleLogout = async () => {
|
||||
setLogoutPending(true)
|
||||
try {
|
||||
await signOut()
|
||||
} finally {
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTitleContext.Provider value={setPageTitle}>
|
||||
<AppShell
|
||||
header={{ height: 72 }}
|
||||
navbar={{ width: 248, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding={{ base: 'md', md: 'xl' }}
|
||||
styles={{
|
||||
header: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
},
|
||||
navbar: {
|
||||
backgroundColor: '#FBF8FA',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
},
|
||||
main: { backgroundColor: 'var(--brand-bg)' },
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" gap="md" wrap="nowrap" justify="space-between">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
color="var(--brand-plum)"
|
||||
aria-label={m.common__nav__toggle()}
|
||||
/>
|
||||
<Link to="/" style={{ textDecoration: 'none' }} onClick={close}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<BrandMark size={32} />
|
||||
<Title
|
||||
order={5}
|
||||
c="var(--brand-plum-dark)"
|
||||
fw={600}
|
||||
style={{ letterSpacing: '0.01em', lineHeight: 1 }}
|
||||
>
|
||||
{m.common__brand__name()}
|
||||
</Title>
|
||||
</Group>
|
||||
</Link>
|
||||
{pageTitle && (
|
||||
<>
|
||||
<Divider orientation="vertical" my={8} visibleFrom="sm" />
|
||||
<Text
|
||||
fw={600}
|
||||
size="md"
|
||||
c="var(--brand-plum-dark)"
|
||||
visibleFrom="sm"
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
{asI18n(`${pageTitle}`)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Menu position="bottom-end" withArrow shadow="sm">
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} align="flex-end" visibleFrom="sm">
|
||||
<Text size="sm" fw={600} style={{ lineHeight: 1.2 }}>{asI18n(`${user.name}`)}</Text>
|
||||
<Text size="xs" c="dimmed" tt="capitalize" style={{ lineHeight: 1.2 }}>
|
||||
{roleLabel[role]()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
color="plum"
|
||||
variant="light"
|
||||
size={36}
|
||||
>
|
||||
{initials(user.name)}
|
||||
</Avatar>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{asI18n(`${user.email}`)}</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutPending}
|
||||
>
|
||||
{m.common__nav__logout()}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<AppShell.Section grow component={ScrollArea}>
|
||||
<Stack gap="lg">
|
||||
{SECTIONS.map((section, i) => {
|
||||
const visible = section.items.filter((n) => n.roles.includes(role))
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<Stack key={i} gap={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
px="sm"
|
||||
mb={4}
|
||||
style={{ letterSpacing: '0.08em' }}
|
||||
>
|
||||
{section.title()}
|
||||
</Text>
|
||||
{visible.map((item) => {
|
||||
// '/admin' is the dashboard landing — match it exactly so it
|
||||
// doesn't stay highlighted across every /admin/* sub-page.
|
||||
const active =
|
||||
item.to === '/admin'
|
||||
? location.pathname === '/admin' ||
|
||||
location.pathname === '/admin/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
component={Link}
|
||||
to={item.to}
|
||||
label={item.label()}
|
||||
active={active}
|
||||
onClick={close}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
<AppShell.Section
|
||||
pt="md"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">{m.common__brand__tagline()}</Text>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
</PageTitleContext.Provider>
|
||||
)
|
||||
}
|
||||
175
apps/app/src/components/BookingActions.tsx
Normal file
175
apps/app/src/components/BookingActions.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Alert, Anchor, Group, Stack } from '@pikku/mantine/core'
|
||||
import { useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { ConfirmButton } from './ConfirmButton'
|
||||
import type { BookingStatus } from '../lib/status'
|
||||
|
||||
/**
|
||||
* The single hub for every booking state transition. Each action is a
|
||||
* two-click {@link ConfirmButton}, gated by the booking's current state so
|
||||
* only the relevant next steps appear. Lives in the detail-page header;
|
||||
* {@link BookingStatusPanel} stays display-only.
|
||||
*/
|
||||
type ActionBooking = {
|
||||
bookingId: string
|
||||
status: BookingStatus
|
||||
contractSentAt: Date | null
|
||||
contractResponse: string | null
|
||||
}
|
||||
|
||||
export function BookingActions({
|
||||
booking,
|
||||
depositPaid,
|
||||
onMutated,
|
||||
}: {
|
||||
booking: ActionBooking
|
||||
depositPaid: boolean
|
||||
onMutated: () => Promise<unknown>
|
||||
}) {
|
||||
useLocale()
|
||||
const { bookingId, status, contractSentAt, contractResponse } = booking
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const runAndRefresh = async (run: () => void) => {
|
||||
setIsRefreshing(true)
|
||||
run()
|
||||
}
|
||||
|
||||
const handleSettled = async () => {
|
||||
await onMutated()
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const setStatus = usePikkuMutation('setBookingStatus', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) })
|
||||
const triggerContract = usePikkuMutation('triggerContractEmail', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) })
|
||||
const contractResp = usePikkuMutation('recordContractResponse', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) })
|
||||
const deposit = usePikkuMutation('recordDepositReceived', { onSuccess: handleSettled, onError: () => setIsRefreshing(false) })
|
||||
const formLink = usePikkuMutation('createBookingFormLink')
|
||||
const actionBusy =
|
||||
isRefreshing ||
|
||||
setStatus.isPending ||
|
||||
triggerContract.isPending ||
|
||||
contractResp.isPending ||
|
||||
deposit.isPending
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap="xs"
|
||||
mt={36}
|
||||
data-testid={actionBusy ? 'booking-actions-pending' : 'booking-actions-ready'}
|
||||
aria-busy={actionBusy}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{status === 'enquiry' && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
color="plum"
|
||||
data-testid="action-approve-enquiry"
|
||||
loading={setStatus.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'reserved' }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__approve_enquiry()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
|
||||
{status === 'reserved' && !contractSentAt && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
variant="light"
|
||||
data-testid="action-send-contract"
|
||||
loading={triggerContract.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => triggerContract.mutate({ bookingId }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__send_contract_now()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
|
||||
{status === 'reserved' && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="plum"
|
||||
data-testid="action-create-form-link"
|
||||
loading={formLink.isPending}
|
||||
onConfirm={() => formLink.mutate({ bookingId })}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__create_form_link()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
|
||||
{status === 'reserved' && contractSentAt && !contractResponse && (
|
||||
<>
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
color="plum"
|
||||
data-testid="action-contract-approved"
|
||||
loading={contractResp.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'approved' }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__record_contract_approved()}
|
||||
</ConfirmButton>
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
data-testid="action-contract-declined"
|
||||
loading={contractResp.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => contractResp.mutate({ bookingId, response: 'declined' }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__record_contract_declined()}
|
||||
</ConfirmButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'reserved' && contractResponse === 'approved' && !depositPaid && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
color="plum"
|
||||
data-testid="action-deposit-received"
|
||||
loading={deposit.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => deposit.mutate({ bookingId }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__record_deposit_received()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
|
||||
{status === 'confirmed' && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
variant="light"
|
||||
data-testid="action-mark-ended"
|
||||
loading={setStatus.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'ended' }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__mark_ended()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
|
||||
{status !== 'ended' && status !== 'cancelled' && (
|
||||
<ConfirmButton
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
data-testid="action-cancel-booking"
|
||||
loading={setStatus.isPending || isRefreshing}
|
||||
onConfirm={() => void runAndRefresh(() => setStatus.mutate({ bookingId, status: 'cancelled' }))}
|
||||
>
|
||||
{m.common__pages__admin_booking__status__actions__cancel_booking()}
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{formLink.data && (
|
||||
<Alert color="plum" variant="light" data-testid="form-link-result">
|
||||
{asI18n(`${m.common__pages__admin_booking__status__form_link__label()} `)}
|
||||
<Anchor href={formLink.data.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{asI18n(`${formLink.data.url}`)}
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
269
apps/app/src/components/BookingStatusPanel.tsx
Normal file
269
apps/app/src/components/BookingStatusPanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { m } from '@/i18n/messages'
|
||||
import { flagReason as flagReasonLabels } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Alert,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
} from '@pikku/mantine/core'
|
||||
import { AlertTriangle, Clock } from 'lucide-react'
|
||||
import { fmtDate } from '../lib/dates'
|
||||
import type { BookingStatus } from '../lib/status'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type DepositInvoice = {
|
||||
invoiceId: string
|
||||
kind: string
|
||||
dueOn: Date | null
|
||||
paidOn: Date | null
|
||||
status: string
|
||||
}
|
||||
|
||||
type BookingMilestones = {
|
||||
bookingId: string
|
||||
status: BookingStatus
|
||||
startDate: Date | null
|
||||
enquiryAt: Date
|
||||
reservedAt: Date | null
|
||||
confirmedAt: Date | null
|
||||
endedAt: Date | null
|
||||
cancelledAt: Date | null
|
||||
contractSentAt: Date | null
|
||||
contractResponse: string | null
|
||||
contractResponseAt: Date | null
|
||||
depositReminderSentAt: Date | null
|
||||
flaggedAt: Date | null
|
||||
flagReason: string | null
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function berlinToday(): string {
|
||||
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
|
||||
function berlinDateStr(d: Date): string {
|
||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
|
||||
function daysBetween(from: Date, toYmd: string): number {
|
||||
return Math.round((Date.parse(toYmd) - from.getTime()) / 86_400_000)
|
||||
}
|
||||
|
||||
function addDaysToDate(d: Date, n: number): Date {
|
||||
const result = new Date(d)
|
||||
result.setUTCDate(result.getUTCDate() + n)
|
||||
return result
|
||||
}
|
||||
|
||||
type NextAction =
|
||||
| { kind: 'flagged'; reason: string }
|
||||
| { kind: 'awaiting_1yr' }
|
||||
| { kind: 'awaiting_signature' }
|
||||
| { kind: 'awaiting_deposit'; dueOn: Date | null }
|
||||
| { kind: 'contract_declined' }
|
||||
| null
|
||||
|
||||
function nextAction(booking: BookingMilestones, deposit: DepositInvoice | null): NextAction {
|
||||
const { status, flaggedAt, flagReason, contractSentAt, contractResponse } = booking
|
||||
if (status === 'cancelled' || status === 'ended' || status === 'confirmed' || status === 'enquiry') return null
|
||||
if (flaggedAt && flagReason === 'contract_declined') return { kind: 'contract_declined' }
|
||||
if (flaggedAt) return { kind: 'flagged', reason: flagReason ?? '' }
|
||||
if (status === 'reserved') {
|
||||
if (!contractSentAt) return { kind: 'awaiting_1yr' }
|
||||
if (!contractResponse) return { kind: 'awaiting_signature' }
|
||||
if (contractResponse === 'approved' && !deposit?.paidOn) return { kind: 'awaiting_deposit', dueOn: deposit?.dueOn ?? null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────
|
||||
|
||||
function DepositProgress({ contractSentAt, deposit, lng }: {
|
||||
contractSentAt: Date
|
||||
deposit: DepositInvoice | null
|
||||
lng: string
|
||||
}) {
|
||||
useLocale()
|
||||
const today = berlinToday()
|
||||
const daysSent = daysBetween(contractSentAt, today)
|
||||
const pct = Math.min(100, Math.round((daysSent / 14) * 100))
|
||||
const color = daysSent >= 14 ? 'red' : daysSent >= 7 ? 'yellow' : 'plum'
|
||||
const dueDate = deposit?.dueOn ?? addDaysToDate(contractSentAt, 14)
|
||||
const dueDateStr = berlinDateStr(dueDate)
|
||||
const overdue = !deposit?.paidOn && dueDateStr < today
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={500}>
|
||||
{deposit?.paidOn
|
||||
? m.common__pages__admin_booking__status__milestones__deposit_paid()
|
||||
: overdue
|
||||
? m.common__pages__admin_booking__status__deposit_overdue()
|
||||
: m.common__pages__admin_booking__status__deposit_window({ day: daysSent })}
|
||||
</Text>
|
||||
{deposit?.paidOn ? null : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(dueDate, lng) })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{!deposit?.paidOn && (
|
||||
<Progress value={pct} color={color} size="sm" radius="xl" />
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────
|
||||
|
||||
export function BookingStatusPanel({
|
||||
booking,
|
||||
invoices,
|
||||
}: {
|
||||
booking: BookingMilestones
|
||||
invoices: DepositInvoice[]
|
||||
}) {
|
||||
useLocale()
|
||||
const lng = getLocale()
|
||||
|
||||
const deposit = invoices.find(inv => inv.kind === 'deposit') ?? null
|
||||
|
||||
const action = nextAction(booking, deposit)
|
||||
|
||||
// ── Stepper steps ──
|
||||
const steps = [
|
||||
{
|
||||
key: 'enquiry',
|
||||
label: m.common__pages__admin_booking__status__milestones__enquiry(),
|
||||
description: fmtDate(booking.enquiryAt, lng),
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
key: 'reserved',
|
||||
label: m.common__pages__admin_booking__status__milestones__reserved(),
|
||||
description: booking.reservedAt ? fmtDate(booking.reservedAt, lng) : null,
|
||||
done: ['reserved', 'confirmed', 'ended'].includes(booking.status),
|
||||
},
|
||||
{
|
||||
key: 'contractSent',
|
||||
label: m.common__pages__admin_booking__status__milestones__contract_sent(),
|
||||
description: booking.contractSentAt ? fmtDate(booking.contractSentAt, lng) : null,
|
||||
done: !!booking.contractSentAt,
|
||||
},
|
||||
{
|
||||
key: 'contractSigned',
|
||||
label: m.common__pages__admin_booking__status__milestones__contract_signed(),
|
||||
description: booking.contractResponseAt && booking.contractResponse === 'approved'
|
||||
? fmtDate(booking.contractResponseAt, lng) : null,
|
||||
done: booking.contractResponse === 'approved',
|
||||
},
|
||||
{
|
||||
key: 'depositPaid',
|
||||
label: m.common__pages__admin_booking__status__milestones__deposit_paid(),
|
||||
description: deposit?.paidOn ? fmtDate(deposit.paidOn, lng) : null,
|
||||
done: !!deposit?.paidOn,
|
||||
},
|
||||
{
|
||||
key: 'confirmed',
|
||||
label: m.common__pages__admin_booking__status__milestones__confirmed(),
|
||||
description: booking.confirmedAt ? fmtDate(booking.confirmedAt, lng) : null,
|
||||
done: ['confirmed', 'ended'].includes(booking.status),
|
||||
},
|
||||
{
|
||||
key: 'ended',
|
||||
label: m.common__pages__admin_booking__status__milestones__ended(),
|
||||
description: booking.endedAt ? fmtDate(booking.endedAt, lng) : null,
|
||||
done: booking.status === 'ended',
|
||||
},
|
||||
]
|
||||
|
||||
const activeStep = (() => {
|
||||
switch (booking.status) {
|
||||
case 'ended': return steps.length // all steps get checkmarks
|
||||
case 'confirmed': return steps.length - 1 // "ended" step is current
|
||||
case 'cancelled': return steps.reduce((acc, s, i) => (s.done ? i : acc), -1)
|
||||
default: return steps.findIndex(s => !s.done) // enquiry/reserved: use milestone data
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Flagged alert */}
|
||||
{booking.flaggedAt && booking.flagReason && (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />} title={m.common__pages__admin_booking__status__flagged()}>
|
||||
{booking.flagReason in flagReasonLabels
|
||||
? flagReasonLabels[booking.flagReason as keyof typeof flagReasonLabels]()
|
||||
: asI18n(booking.flagReason)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── Horizontal stepper ── */}
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
color={booking.status === 'cancelled' ? 'red' : 'plum'}
|
||||
size="xs"
|
||||
styles={{ stepLabel: { fontSize: 'var(--mantine-font-size-xs)' }, stepDescription: { fontSize: 'var(--mantine-font-size-xs)' } }}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Stepper.Step
|
||||
key={step.key}
|
||||
label={step.label}
|
||||
description={asI18n(step.description ?? 'N/A')}
|
||||
/>
|
||||
))}
|
||||
{booking.status === 'cancelled' && (
|
||||
<Stepper.Step
|
||||
key="cancelled"
|
||||
color="red"
|
||||
label={m.common__pages__admin_booking__status__milestones__cancelled()}
|
||||
description={booking.cancelledAt ? asI18n(fmtDate(booking.cancelledAt, lng)) : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stepper>
|
||||
|
||||
{/* Deposit progress bar (when contract sent but deposit not yet paid) */}
|
||||
{booking.contractSentAt && !deposit?.paidOn && (
|
||||
<DepositProgress contractSentAt={booking.contractSentAt} deposit={deposit} lng={lng} />
|
||||
)}
|
||||
|
||||
{/* ── Next action alert (informational; controls live in BookingActions) ── */}
|
||||
{action && (
|
||||
<Alert
|
||||
color={
|
||||
action.kind === 'flagged' || action.kind === 'contract_declined' ? 'red'
|
||||
: action.kind === 'awaiting_deposit' ? 'orange'
|
||||
: 'blue'
|
||||
}
|
||||
icon={<Clock size={16} />}
|
||||
>
|
||||
{action.kind === 'flagged' &&
|
||||
(action.reason in flagReasonLabels
|
||||
? flagReasonLabels[action.reason as keyof typeof flagReasonLabels]()
|
||||
: asI18n(action.reason))}
|
||||
{action.kind === 'awaiting_1yr' && m.common__pages__admin_booking__status__contract_not_sent_yet({
|
||||
date: booking.startDate ? fmtDate(addDaysToDate(booking.startDate, -365), lng) : '—',
|
||||
})}
|
||||
{action.kind === 'awaiting_signature' && m.common__pages__admin_booking__status__awaiting_signature()}
|
||||
{action.kind === 'awaiting_deposit' && (
|
||||
<>
|
||||
{m.common__pages__admin_booking__status__awaiting_deposit()}
|
||||
{action.dueOn && (
|
||||
<Text size="xs" mt={2}>
|
||||
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(action.dueOn, lng) })}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action.kind === 'contract_declined' && flagReasonLabels.contract_declined()}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
34
apps/app/src/components/Brand.tsx
Normal file
34
apps/app/src/components/Brand.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Group, Stack, Text, Title } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
type Variant = 'plum' | 'white'
|
||||
|
||||
export function BrandMark({
|
||||
size = 48,
|
||||
variant = 'plum',
|
||||
}: {
|
||||
size?: number
|
||||
variant?: Variant
|
||||
}) {
|
||||
const src = variant === 'white' ? '/brand/logo-white.svg' : '/brand/logo-plum.svg'
|
||||
return <img src={src} alt="Seminarhof Drawehn" width={size} height={size} />
|
||||
}
|
||||
|
||||
export function BrandHeader({ variant = 'plum' }: { variant?: Variant }) {
|
||||
useLocale()
|
||||
const color = variant === 'white' ? 'white' : 'var(--brand-plum)'
|
||||
return (
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<BrandMark variant={variant} size={56} />
|
||||
<Stack gap={2}>
|
||||
<Title order={2} c={color} style={{ letterSpacing: '0.02em' }}>
|
||||
{m.common__brand__name()}
|
||||
</Title>
|
||||
<Text size="sm" c={variant === 'white' ? 'white' : 'dimmed'} fs="italic">
|
||||
{m.common__brand__tagline()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
72
apps/app/src/components/ConfirmButton.tsx
Normal file
72
apps/app/src/components/ConfirmButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, type ButtonProps } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { type I18nString, type I18nNode } from '@pikku/react'
|
||||
|
||||
/**
|
||||
* A button that requires two clicks to fire, guarding against accidental
|
||||
* state transitions. The first click "arms" it — the label swaps to a
|
||||
* confirm prompt and the colour goes red; the second click commits and
|
||||
* disarms. If left untouched it auto-reverts after `resetMs`.
|
||||
*
|
||||
* Disarming happens synchronously on the confirming click (not on mutation
|
||||
* success), so a failed mutation leaves a normal, retryable button — the
|
||||
* caller's `loading` prop drives the in-flight visual.
|
||||
*/
|
||||
type ConfirmButtonProps = ButtonProps & {
|
||||
onConfirm: () => void
|
||||
/** Label shown in the armed state. Defaults to the localized "Confirm?". */
|
||||
confirmLabel?: I18nString
|
||||
/** Colour while armed. */
|
||||
confirmColor?: string
|
||||
/** Auto-revert delay in ms. */
|
||||
resetMs?: number
|
||||
children?: I18nNode
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
export function ConfirmButton({
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
confirmColor = 'red',
|
||||
resetMs = 3000,
|
||||
color,
|
||||
variant,
|
||||
children,
|
||||
'data-testid': testId,
|
||||
...rest
|
||||
}: ConfirmButtonProps) {
|
||||
useLocale()
|
||||
const [armed, setArmed] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
const handleClick = () => {
|
||||
if (armed) {
|
||||
clearTimeout(timer.current)
|
||||
setArmed(false)
|
||||
onConfirm()
|
||||
return
|
||||
}
|
||||
setArmed(true)
|
||||
timer.current = setTimeout(() => setArmed(false), resetMs)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
key={armed ? 'armed' : 'idle'}
|
||||
data-testid={armed && testId ? `${testId}-confirm` : testId}
|
||||
data-confirm-state={armed ? 'armed' : 'idle'}
|
||||
color={armed ? confirmColor : color}
|
||||
variant={armed ? 'filled' : variant}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{armed
|
||||
? confirmLabel ?? m.common__pages__admin_booking__status__actions__confirm()
|
||||
: children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
69
apps/app/src/components/DateBar.tsx
Normal file
69
apps/app/src/components/DateBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ActionIcon, Button, Group, TextInput, Tooltip } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
|
||||
export const todayIso = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
export const shiftDate = (iso: string, days: number) => {
|
||||
const d = new Date(iso + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function DateBar({
|
||||
date,
|
||||
onChange,
|
||||
}: {
|
||||
date: string
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
useLocale()
|
||||
const isToday = date === todayIso()
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="center" data-testid="date-bar">
|
||||
<Tooltip label={m.common__pages__staff__prev_day()}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={() => onChange(shiftDate(date, -1))}
|
||||
aria-label={m.common__pages__staff__prev_day()}
|
||||
data-testid="date-bar-prev"
|
||||
>
|
||||
‹
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<TextInput
|
||||
type="date"
|
||||
size="sm"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value
|
||||
if (v) onChange(v)
|
||||
}}
|
||||
data-testid="date-bar-input"
|
||||
styles={{ input: { minWidth: 160 } }}
|
||||
/>
|
||||
<Tooltip label={m.common__pages__staff__next_day()}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={() => onChange(shiftDate(date, 1))}
|
||||
aria-label={m.common__pages__staff__next_day()}
|
||||
data-testid="date-bar-next"
|
||||
>
|
||||
›
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={isToday ? 'filled' : 'light'}
|
||||
color="plum"
|
||||
onClick={() => onChange(todayIso())}
|
||||
disabled={isToday}
|
||||
data-testid="date-bar-today"
|
||||
>
|
||||
{m.common__pages__staff__today()}
|
||||
</Button>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
86
apps/app/src/components/EventCard.tsx
Normal file
86
apps/app/src/components/EventCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { Box, Button, Card, Flex, Image, Stack, Text, Title } from '@pikku/mantine/core'
|
||||
import { ImageOff } from 'lucide-react'
|
||||
|
||||
type EventCardProps = {
|
||||
eventName: string
|
||||
startDate: Date | null
|
||||
endDate: Date | null
|
||||
clientName?: string | null
|
||||
coverImageUrl?: string | null
|
||||
eventOutline?: string | null
|
||||
description?: string | null
|
||||
organiserWebsite?: string | null
|
||||
}
|
||||
|
||||
function formatDateRange(start: Date | null, end: Date | null, lng: string): string {
|
||||
const locale = lng.startsWith('de') ? 'de-DE' : 'en-GB'
|
||||
const fmt = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
// An enquiry may have no dates yet; show whatever we have, or a placeholder.
|
||||
if (!start && !end) return '—'
|
||||
if (!start || !end) return fmt.format((start ?? end) as Date)
|
||||
return start.toISOString().slice(0, 10) === end.toISOString().slice(0, 10)
|
||||
? fmt.format(start)
|
||||
: fmt.formatRange(start, end)
|
||||
}
|
||||
|
||||
export function EventCard({
|
||||
eventName,
|
||||
startDate,
|
||||
endDate,
|
||||
clientName,
|
||||
coverImageUrl,
|
||||
eventOutline,
|
||||
description,
|
||||
organiserWebsite,
|
||||
}: EventCardProps) {
|
||||
useLocale()
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
|
||||
<Flex>
|
||||
<Box w={{ base: 120, sm: 220 }} style={{ flexShrink: 0 }}>
|
||||
{coverImageUrl ? (
|
||||
<Image src={coverImageUrl} alt={eventName} h="100%" w="100%" fit="cover" />
|
||||
) : (
|
||||
<Flex h="100%" mih={140} align="center" justify="center" bg="gray.1">
|
||||
<ImageOff size={28} color="var(--mantine-color-gray-5)" />
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
<Stack gap="xs" p="md" style={{ flexGrow: 1 }}>
|
||||
<Title order={4}>{asI18n(`${eventName}`)}</Title>
|
||||
<Text size="sm" fw={500}>
|
||||
{asI18n(`${formatDateRange(startDate, endDate, getLocale())}`)}
|
||||
</Text>
|
||||
{clientName && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`${clientName}`)}
|
||||
</Text>
|
||||
)}
|
||||
{(eventOutline || description) && (
|
||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
||||
{asI18n(`${eventOutline || description}`)}
|
||||
</Text>
|
||||
)}
|
||||
{organiserWebsite && (
|
||||
<Button
|
||||
component="a"
|
||||
href={organiserWebsite}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="auto"
|
||||
w="fit-content"
|
||||
>
|
||||
{m.common__pages__events__more_info()}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
34
apps/app/src/components/MarketingShell.tsx
Normal file
34
apps/app/src/components/MarketingShell.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Box } from '@pikku/mantine/core'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { AppLayout } from './AppLayout'
|
||||
import { SeminarhofHeader, SeminarhofFooter } from './SeminarhofChrome'
|
||||
|
||||
/**
|
||||
* Shell for the public marketing-facing pages (availability / events / enquiry).
|
||||
* Logged-in users keep the authenticated AppLayout (sidebar) so the admin
|
||||
* experience is consistent. Anonymous visitors get the seminarhof-drawehn.de
|
||||
* header + footer so the app and the marketing site feel like one website.
|
||||
*
|
||||
* Deliberately a sibling of PublicShell (not a variant) — pages opt into the
|
||||
* marketing chrome explicitly, so a future public route (e.g. kanban) can't
|
||||
* inherit it by accident.
|
||||
*/
|
||||
export function MarketingShell({ children }: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = usePikkuQuery('me', null, { retry: false })
|
||||
|
||||
if (isLoading) {
|
||||
return <Box mih="100vh" />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return <AppLayout user={data}>{children}</AppLayout>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mih="100vh" style={{ display: 'flex', flexDirection: 'column', background: 'var(--brand-bg)' }}>
|
||||
<SeminarhofHeader />
|
||||
<Box style={{ flex: 1 }}>{children}</Box>
|
||||
<SeminarhofFooter />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
609
apps/app/src/components/ParticipantsTab.tsx
Normal file
609
apps/app/src/components/ParticipantsTab.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import {
|
||||
dietary as dietaryLabels,
|
||||
participantKind as kindLabels,
|
||||
severity as severityLabels,
|
||||
allergen as allergenLabels,
|
||||
} from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n, type I18nString } from '@pikku/react'
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@pikku/mantine/core'
|
||||
|
||||
type SeverityKey = 'mild' | 'moderate' | 'severe' | 'standard'
|
||||
|
||||
type Allergy = {
|
||||
allergen: string
|
||||
severity: string
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
type Participant = {
|
||||
participantId: string
|
||||
name: string
|
||||
kind: string
|
||||
dietaryTag: string | null
|
||||
roomId: string | null
|
||||
roomNumber: number | null
|
||||
allergies: Allergy[]
|
||||
}
|
||||
|
||||
type DietaryTag =
|
||||
| 'omnivore'
|
||||
| 'vegetarian'
|
||||
| 'vegan'
|
||||
| 'gluten_free'
|
||||
| 'lactose_free'
|
||||
| 'pescatarian'
|
||||
| 'unknown'
|
||||
type Kind = 'overnight' | 'day_guest'
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
const TINTS = [
|
||||
'#88557F',
|
||||
'#A05C8E',
|
||||
'#BC7AA6',
|
||||
'#D3A7C4',
|
||||
'#FFBC7D',
|
||||
'#F37E15',
|
||||
'#FFCE00',
|
||||
'#806600',
|
||||
'#4E3149',
|
||||
'#583651',
|
||||
]
|
||||
const tintFor = (id: string) => {
|
||||
let h = 0
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
||||
return TINTS[Math.abs(h) % TINTS.length]
|
||||
}
|
||||
|
||||
const SEVERITY_STYLE: Record<
|
||||
SeverityKey,
|
||||
{ bg: string; border: string; fg: string }
|
||||
> = {
|
||||
mild: { bg: '#F1EEF0', border: '#D9D2D7', fg: '#5A4F56' },
|
||||
moderate: { bg: '#FFE7CF', border: '#FFBC7D', fg: '#7A4A18' },
|
||||
severe: { bg: '#4E3149', border: '#4E3149', fg: '#FFFFFF' },
|
||||
standard: { bg: '#F5EAF1', border: '#E0CCD9', fg: '#4E3149' },
|
||||
}
|
||||
|
||||
const severityKey = (s: string): SeverityKey =>
|
||||
s === 'mild' || s === 'moderate' || s === 'severe' || s === 'standard'
|
||||
? s
|
||||
: 'standard'
|
||||
|
||||
const DIETARY_VALUES: DietaryTag[] = [
|
||||
'omnivore',
|
||||
'vegetarian',
|
||||
'vegan',
|
||||
'gluten_free',
|
||||
'lactose_free',
|
||||
'pescatarian',
|
||||
'unknown',
|
||||
]
|
||||
|
||||
export function ParticipantsTab({
|
||||
participants,
|
||||
bookingId,
|
||||
addParticipant,
|
||||
updateParticipant,
|
||||
removeParticipant,
|
||||
addAllergy,
|
||||
removeAllergy,
|
||||
isAddingParticipant,
|
||||
}: {
|
||||
participants: Participant[]
|
||||
bookingId: string
|
||||
addParticipant: (args: {
|
||||
bookingId: string
|
||||
name: string
|
||||
kind: Kind
|
||||
email: null
|
||||
age: null
|
||||
notes: null
|
||||
}, opts?: { onSuccess?: () => void }) => void
|
||||
updateParticipant: (args: {
|
||||
participantId: string
|
||||
dietaryTag: DietaryTag | null
|
||||
}) => void
|
||||
removeParticipant: (args: { participantId: string }) => void
|
||||
addAllergy: (
|
||||
args: {
|
||||
participantId: string
|
||||
allergen: string
|
||||
severity: 'standard' | 'separate_prep' | 'severe'
|
||||
},
|
||||
opts?: { onSuccess?: () => void },
|
||||
) => void
|
||||
removeAllergy: (args: { participantId: string; allergen: string }) => void
|
||||
isAddingParticipant: boolean
|
||||
}) {
|
||||
useLocale()
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newKind, setNewKind] = useState<Kind>('overnight')
|
||||
const [newAllergyByPid, setNewAllergyByPid] = useState<Record<string, string>>({})
|
||||
const [allergyOpenByPid, setAllergyOpenByPid] = useState<Record<string, boolean>>({})
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const overnight = participants.filter((p) => p.kind === 'overnight')
|
||||
const day = participants.filter((p) => p.kind === 'day_guest')
|
||||
return [
|
||||
{ kind: 'overnight' as const, items: overnight },
|
||||
{ kind: 'day_guest' as const, items: day },
|
||||
].filter((g) => g.items.length > 0)
|
||||
}, [participants])
|
||||
|
||||
const dietaryOptions = DIETARY_VALUES.map((v) => ({
|
||||
value: v,
|
||||
label: dietaryLabels[v](),
|
||||
}))
|
||||
|
||||
const submitAdd = () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
addParticipant(
|
||||
{ bookingId, name, kind: newKind, email: null, age: null, notes: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
setNewKind('overnight')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Inline compact add-participant bar -------------------------------- */}
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
data-testid="add-participant-form"
|
||||
style={{ background: '#FBF8FA', borderColor: '#E5DDE3' }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: '0.08em', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{m.common__pages__booking__participants__add_modal__title()}
|
||||
</Text>
|
||||
<TextInput
|
||||
size="sm"
|
||||
data-testid="new-participant-name"
|
||||
placeholder={m.common__pages__booking__participants__add_modal__name()}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitAdd()
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
aria-label={m.common__pages__booking__participants__add_modal__name()}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
data-testid="new-participant-kind"
|
||||
value={newKind}
|
||||
onChange={(v) => setNewKind(((v as Kind) ?? 'overnight'))}
|
||||
data={[
|
||||
{
|
||||
value: 'overnight',
|
||||
label: kindLabels.overnight(),
|
||||
},
|
||||
{
|
||||
value: 'day_guest',
|
||||
label: kindLabels.day_guest(),
|
||||
},
|
||||
]}
|
||||
allowDeselect={false}
|
||||
style={{ width: 160 }}
|
||||
aria-label={m.common__pages__booking__participants__add_modal__kind()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="add-participant-submit"
|
||||
disabled={!newName.trim() || isAddingParticipant}
|
||||
loading={isAddingParticipant}
|
||||
onClick={submitAdd}
|
||||
color="plum"
|
||||
>
|
||||
{m.common__pages__booking__participants__add_modal__add()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{participants.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.common__pages__booking__participants__empty()}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{groups.map((g) => (
|
||||
<Stack key={g.kind} gap="xs">
|
||||
<Group justify="space-between" align="baseline">
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
style={{ letterSpacing: '0.08em' }}
|
||||
>
|
||||
{kindLabels[g.kind]()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.common__pages__booking__participants__total_participants({
|
||||
count: g.items.length,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{g.items.map((p) => (
|
||||
<ParticipantRow
|
||||
key={p.participantId}
|
||||
p={p}
|
||||
dietaryOptions={dietaryOptions}
|
||||
onDietChange={(v) =>
|
||||
updateParticipant({
|
||||
participantId: p.participantId,
|
||||
dietaryTag: v,
|
||||
})
|
||||
}
|
||||
onRemove={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
m.common__pages__booking__participants__remove_confirm({
|
||||
name: p.name,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
removeParticipant({ participantId: p.participantId })
|
||||
}
|
||||
}}
|
||||
allergyDraft={newAllergyByPid[p.participantId] ?? ''}
|
||||
onAllergyDraftChange={(v) =>
|
||||
setNewAllergyByPid((m) => ({
|
||||
...m,
|
||||
[p.participantId]: v,
|
||||
}))
|
||||
}
|
||||
allergyEditorOpen={allergyOpenByPid[p.participantId] ?? true}
|
||||
onToggleAllergyEditor={() =>
|
||||
setAllergyOpenByPid((m) => ({
|
||||
...m,
|
||||
[p.participantId]: !m[p.participantId],
|
||||
}))
|
||||
}
|
||||
onAddAllergy={() => {
|
||||
const val = (newAllergyByPid[p.participantId] ?? '').trim()
|
||||
if (!val) return
|
||||
addAllergy(
|
||||
{
|
||||
participantId: p.participantId,
|
||||
allergen: val,
|
||||
severity: 'standard',
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
setNewAllergyByPid((m) => ({
|
||||
...m,
|
||||
[p.participantId]: '',
|
||||
})),
|
||||
},
|
||||
)
|
||||
}}
|
||||
onRemoveAllergy={(allergen) =>
|
||||
removeAllergy({
|
||||
participantId: p.participantId,
|
||||
allergen,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantRow({
|
||||
p,
|
||||
dietaryOptions,
|
||||
onDietChange,
|
||||
onRemove,
|
||||
allergyDraft,
|
||||
onAllergyDraftChange,
|
||||
allergyEditorOpen,
|
||||
onToggleAllergyEditor,
|
||||
onAddAllergy,
|
||||
onRemoveAllergy,
|
||||
}: {
|
||||
p: Participant
|
||||
dietaryOptions: { value: string; label: string }[]
|
||||
onDietChange: (v: DietaryTag | null) => void
|
||||
onRemove: () => void
|
||||
allergyDraft: string
|
||||
onAllergyDraftChange: (v: string) => void
|
||||
allergyEditorOpen: boolean
|
||||
onToggleAllergyEditor: () => void
|
||||
onAddAllergy: () => void
|
||||
onRemoveAllergy: (allergen: string) => void
|
||||
}) {
|
||||
useLocale()
|
||||
const tint = tintFor(p.participantId)
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
data-testid={`participant-row-${p.participantId}`}
|
||||
style={{ borderColor: '#E5DDE3' }}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Box
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns:
|
||||
'auto minmax(160px, 1.4fr) minmax(140px, 1fr) auto auto',
|
||||
gap: 'var(--mantine-spacing-md)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
background: tint,
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initials(p.name) || '·'}
|
||||
</Box>
|
||||
|
||||
{/* Name + room */}
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" style={{ lineHeight: 1.2 }}>
|
||||
{asI18n(`${p.name}`)}
|
||||
</Text>
|
||||
{p.roomNumber != null ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${m.common__pages__booking__participants__cols__room()} #${p.roomNumber}`)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{asI18n('—')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Diet pill (select) */}
|
||||
<Select
|
||||
size="xs"
|
||||
data-testid={`participant-diet-${p.participantId}`}
|
||||
aria-label={m.common__pages__booking__participants__cols__diet()}
|
||||
value={p.dietaryTag}
|
||||
onChange={(v) => onDietChange((v as DietaryTag | null) ?? null)}
|
||||
data={dietaryOptions}
|
||||
clearable
|
||||
placeholder={dietaryLabels.unknown()}
|
||||
/>
|
||||
|
||||
{/* Allergy summary chip */}
|
||||
<AllergySummary count={p.allergies.length} />
|
||||
|
||||
{/* Delete */}
|
||||
<Tooltip label={m.common__actions__delete()} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
data-testid={`remove-participant-${p.participantId}`}
|
||||
onClick={onRemove}
|
||||
aria-label={m.common__actions__delete()}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 16, lineHeight: 1 }}>×</span>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Allergy chips + editor */}
|
||||
<Box style={{ paddingLeft: 52 }}>
|
||||
{p.allergies.length === 0 && !allergyEditorOpen ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="plum"
|
||||
onClick={onToggleAllergyEditor}
|
||||
styles={{ root: { paddingLeft: 0, paddingRight: 0 } }}
|
||||
>
|
||||
{m.common__pages__booking__participants__allergy_add()}
|
||||
</Button>
|
||||
) : (
|
||||
<Group gap="xs" wrap="wrap" align="center">
|
||||
{p.allergies.map((a) => (
|
||||
<AllergyChip
|
||||
key={a.allergen}
|
||||
allergy={a}
|
||||
onRemove={() => onRemoveAllergy(a.allergen)}
|
||||
testId={`remove-allergy-${p.participantId}-${a.allergen}`}
|
||||
removeLabel={m.common__pages__booking__participants__allergy_remove_aria({
|
||||
allergen: a.allergen,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{!allergyEditorOpen ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="plum"
|
||||
onClick={onToggleAllergyEditor}
|
||||
>
|
||||
{m.common__pages__booking__participants__allergy_add()}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{allergyEditorOpen && (
|
||||
<Group gap="xs" mt="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={m.common__pages__admin_booking__allergies__add_placeholder()}
|
||||
data-testid={`new-allergy-${p.participantId}`}
|
||||
value={allergyDraft}
|
||||
onChange={(e) => onAllergyDraftChange(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onAddAllergy()
|
||||
}}
|
||||
style={{ flex: 1, maxWidth: 280 }}
|
||||
/>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="plum"
|
||||
data-testid={`add-allergy-${p.participantId}`}
|
||||
disabled={!allergyDraft.trim()}
|
||||
onClick={onAddAllergy}
|
||||
>
|
||||
{m.common__pages__admin_booking__allergies__add()}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onToggleAllergyEditor}
|
||||
>
|
||||
{m.common__actions__cancel({ defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function AllergySummary({ count }: { count: number }) {
|
||||
useLocale()
|
||||
if (count === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.common__pages__booking__participants__allergy_none()}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="peach"
|
||||
size="sm"
|
||||
styles={{
|
||||
root: {
|
||||
background: '#FFE7CF',
|
||||
color: '#7A4A18',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{m.common__pages__booking__participants__allergy_count({ count })}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function AllergyChip({
|
||||
allergy,
|
||||
onRemove,
|
||||
testId,
|
||||
removeLabel,
|
||||
}: {
|
||||
allergy: Allergy
|
||||
onRemove: () => void
|
||||
testId: string
|
||||
removeLabel: I18nString
|
||||
}) {
|
||||
useLocale()
|
||||
const sev = severityKey(allergy.severity)
|
||||
const style = SEVERITY_STYLE[sev]
|
||||
const sevLabel =
|
||||
sev === 'standard'
|
||||
? null
|
||||
: severityLabels[sev]()
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
color: style.fg,
|
||||
borderRadius: 999,
|
||||
padding: '2px 4px 2px 10px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<span>{allergy.allergen in allergenLabels
|
||||
? allergenLabels[allergy.allergen as keyof typeof allergenLabels]()
|
||||
: asI18n(allergy.allergen)}</span>
|
||||
{sevLabel ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
· {sevLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
data-testid={testId}
|
||||
onClick={onRemove}
|
||||
aria-label={removeLabel}
|
||||
style={{ color: style.fg }}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 12, lineHeight: 1 }}>×</span>
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
62
apps/app/src/components/PublicShell.tsx
Normal file
62
apps/app/src/components/PublicShell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Box, Button, Container, Group, Title } from '@pikku/mantine/core'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { BrandMark } from './Brand'
|
||||
import { AppLayout } from './AppLayout'
|
||||
|
||||
/**
|
||||
* Shell for routes that are accessible to anonymous visitors. If the user is
|
||||
* logged in, we render them inside the authenticated AppLayout so the sidebar
|
||||
* stays consistent across the app. Otherwise we render a slim public header.
|
||||
*/
|
||||
export function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
useLocale()
|
||||
const { data, isLoading } = usePikkuQuery('me', null, { retry: false })
|
||||
|
||||
if (isLoading) {
|
||||
return <Box mih="100vh" />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return <AppLayout user={data}>{children}</AppLayout>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mih="100vh" style={{ background: 'var(--brand-bg)' }}>
|
||||
<Box
|
||||
component="header"
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
height: 72,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Container size="xl" w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Link to="/" style={{ textDecoration: 'none' }}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<BrandMark size={32} />
|
||||
<Title
|
||||
order={5}
|
||||
c="var(--brand-plum-dark)"
|
||||
fw={600}
|
||||
style={{ letterSpacing: '0.01em', lineHeight: 1 }}
|
||||
>
|
||||
{m.common__brand__name()}
|
||||
</Title>
|
||||
</Group>
|
||||
</Link>
|
||||
<Button component={Link} to="/login" variant="light" color="plum" size="sm">
|
||||
{m.common__nav__login()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
<Box>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
493
apps/app/src/components/RoomsTab.tsx
Normal file
493
apps/app/src/components/RoomsTab.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { building as buildingLabels, floor as floorLabels } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@pikku/mantine/core'
|
||||
|
||||
type Room = {
|
||||
roomId: string
|
||||
number: number
|
||||
beds: number
|
||||
roomType: string
|
||||
building: string
|
||||
floor: string
|
||||
wheelchair: number
|
||||
groundFloor: number
|
||||
quiet: number
|
||||
sharedBath: number
|
||||
dogsAllowed: number
|
||||
doubleBed_140: number
|
||||
allergyFriendly: number
|
||||
occupiedByOtherBooking: boolean
|
||||
assignedParticipants: { participantId: string; name: string }[]
|
||||
}
|
||||
|
||||
type Participant = { participantId: string; name: string; roomId: string | null }
|
||||
|
||||
const PALETTE = {
|
||||
empty: '#FBF8FA',
|
||||
emptyBorder: '#E5DDE3',
|
||||
filled: '#E9D4E2',
|
||||
filledBorder: '#88557F',
|
||||
partial: '#F0E4EC',
|
||||
full: '#88557F',
|
||||
fullText: '#FFFFFF',
|
||||
occupiedOther: '#EDEDED',
|
||||
occupiedOtherText: '#9B9499',
|
||||
hover: '#FFCE00',
|
||||
}
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
// Stable, soft per-participant tint so each guest reads as a distinct chip
|
||||
// in the floor plan without leaving the brand palette.
|
||||
const TINTS = [
|
||||
'#88557F',
|
||||
'#A05C8E',
|
||||
'#BC7AA6',
|
||||
'#D3A7C4',
|
||||
'#FFBC7D',
|
||||
'#F37E15',
|
||||
'#FFCE00',
|
||||
'#806600',
|
||||
'#4E3149',
|
||||
'#583651',
|
||||
]
|
||||
const tintFor = (id: string) => {
|
||||
let h = 0
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
||||
return TINTS[Math.abs(h) % TINTS.length]
|
||||
}
|
||||
|
||||
type Group = { building: string; floor: string; rooms: Room[] }
|
||||
|
||||
function groupRooms(rooms: Room[]): Group[] {
|
||||
const map = new Map<string, Room[]>()
|
||||
for (const r of rooms) {
|
||||
const key = `${r.building}::${r.floor}`
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(r)
|
||||
}
|
||||
const order = ['gaestehaus::eg', 'gaestehaus::og', 'haupthaus::eg', 'haupthaus::og']
|
||||
return Array.from(map.entries())
|
||||
.map(([k, rs]) => {
|
||||
const [building, floor] = k.split('::')
|
||||
return { building, floor, rooms: rs.sort((a, b) => a.number - b.number) }
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
order.indexOf(`${a.building}::${a.floor}`) - order.indexOf(`${b.building}::${b.floor}`),
|
||||
)
|
||||
}
|
||||
|
||||
function buildingLabel(building: string, floor: string) {
|
||||
const b = building in buildingLabels ? buildingLabels[building as keyof typeof buildingLabels]() : asI18n(building)
|
||||
const f = floor in floorLabels ? floorLabels[floor as keyof typeof floorLabels]() : asI18n(floor)
|
||||
return `${b} · ${f}`
|
||||
}
|
||||
|
||||
export function RoomsTab({
|
||||
rooms,
|
||||
participants,
|
||||
bookingId,
|
||||
assign,
|
||||
unassign,
|
||||
}: {
|
||||
rooms: Room[]
|
||||
participants: Participant[]
|
||||
bookingId: string
|
||||
assign: (args: { bookingId: string; participantId: string; roomId: string }) => void
|
||||
unassign: (args: { participantId: string }) => void
|
||||
}) {
|
||||
useLocale()
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
const groups = useMemo(() => groupRooms(rooms), [rooms])
|
||||
const unassigned = participants.filter((p) => !p.roomId)
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" data-testid="rooms-tab">
|
||||
{/* Left pane: room list ----------------------------------------- */}
|
||||
<Paper withBorder radius="md" p={0}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 64 }}>{m.common__pages__admin_booking__rooms__cols__room()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin_booking__rooms__cols__guests()}</Table.Th>
|
||||
<Table.Th style={{ width: 200 }}>{m.common__pages__admin_booking__rooms__cols__assign()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{groups.map((g) => (
|
||||
<RoomGroupRows
|
||||
key={`${g.building}-${g.floor}`}
|
||||
group={g}
|
||||
hovered={hovered}
|
||||
onHover={setHovered}
|
||||
unassigned={unassigned}
|
||||
assign={assign}
|
||||
unassign={unassign}
|
||||
bookingId={bookingId}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Right pane: SVG floorplan -------------------------------------- */}
|
||||
<Stack gap="md">
|
||||
{groups.map((g) => (
|
||||
<Paper key={`${g.building}-${g.floor}`} withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
|
||||
{asI18n(`${buildingLabel(g.building, g.floor)}`)}
|
||||
</Text>
|
||||
<FloorPlan
|
||||
rooms={g.rooms}
|
||||
hovered={hovered}
|
||||
onHover={setHovered}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomGroupRows({
|
||||
group,
|
||||
hovered,
|
||||
onHover,
|
||||
unassigned,
|
||||
assign,
|
||||
unassign,
|
||||
bookingId,
|
||||
}: {
|
||||
group: Group
|
||||
hovered: string | null
|
||||
onHover: (id: string | null) => void
|
||||
unassigned: Participant[]
|
||||
assign: (args: { bookingId: string; participantId: string; roomId: string }) => void
|
||||
unassign: (args: { participantId: string }) => void
|
||||
bookingId: string
|
||||
}) {
|
||||
useLocale()
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3} style={{ background: '#FBF8FA' }}>
|
||||
<Text size="xs" tt="uppercase" c="dimmed" fw={600} style={{ letterSpacing: '0.08em' }}>
|
||||
{asI18n(`${buildingLabel(group.building, group.floor)}`)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{group.rooms.map((r) => (
|
||||
<Table.Tr
|
||||
key={r.roomId}
|
||||
onMouseEnter={() => onHover(r.roomId)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
style={{
|
||||
background: hovered === r.roomId ? 'rgba(255, 206, 0, 0.12)' : undefined,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700}>{r.number}</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{asI18n(`${r.roomType}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{asI18n(`${r.beds} ${m.common__pages__admin_booking__rooms__beds()}`)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.occupiedByOtherBooking ? (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{m.common__pages__admin_booking__rooms__occupied_other()}
|
||||
</Text>
|
||||
) : r.assignedParticipants.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{r.assignedParticipants.map((p) => (
|
||||
<Group key={p.participantId} gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
background: tintFor(p.participantId),
|
||||
color: '#fff',
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initials(p.name)}
|
||||
</Box>
|
||||
<Text size="sm">{asI18n(`${p.name}`)}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => unassign({ participantId: p.participantId })}
|
||||
>
|
||||
{m.common__pages__admin_booking__rooms__unassign()}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!r.occupiedByOtherBooking && unassigned.length > 0 && (
|
||||
<Select
|
||||
size="xs"
|
||||
data-testid={`assign-room-${r.roomId}`}
|
||||
placeholder={m.common__pages__admin_booking__rooms__assign_to_participant()}
|
||||
data={unassigned.map((p) => ({ value: p.participantId, label: p.name }))}
|
||||
value={null}
|
||||
onChange={(v) =>
|
||||
v && assign({ bookingId, participantId: v, roomId: r.roomId })
|
||||
}
|
||||
clearable={false}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── SVG floor plan ─────────────────────────────────────────────────────
|
||||
// Schematic only — not architecturally accurate. Rooms are laid out as a
|
||||
// grid with their real number; layout is responsive to the room count per
|
||||
// floor section.
|
||||
|
||||
function FloorPlan({
|
||||
rooms,
|
||||
hovered,
|
||||
onHover,
|
||||
}: {
|
||||
rooms: Room[]
|
||||
hovered: string | null
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
const cols = Math.min(6, Math.max(2, Math.ceil(Math.sqrt(rooms.length * 1.6))))
|
||||
const rows = Math.ceil(rooms.length / cols)
|
||||
const W = 480
|
||||
const cellGap = 6
|
||||
const padX = 12
|
||||
const padY = 12
|
||||
const cellW = (W - padX * 2 - cellGap * (cols - 1)) / cols
|
||||
const cellH = 84
|
||||
const H = padY * 2 + rows * cellH + (rows - 1) * cellGap
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%' }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
style={{ display: 'block', borderRadius: 8 }}
|
||||
>
|
||||
<rect x="0" y="0" width={W} height={H} fill="#ffffff" stroke="#E5DDE3" rx="8" />
|
||||
{rooms.map((r, i) => {
|
||||
const c = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
const x = padX + c * (cellW + cellGap)
|
||||
const y = padY + row * (cellH + cellGap)
|
||||
return (
|
||||
<RoomCell
|
||||
key={r.roomId}
|
||||
room={r}
|
||||
x={x}
|
||||
y={y}
|
||||
w={cellW}
|
||||
h={cellH}
|
||||
hovered={hovered === r.roomId}
|
||||
onHover={onHover}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomCell({
|
||||
room: r,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
hovered,
|
||||
onHover,
|
||||
}: {
|
||||
room: Room
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
hovered: boolean
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
const isOther = r.occupiedByOtherBooking
|
||||
const count = r.assignedParticipants.length
|
||||
const isFull = count >= r.beds && r.beds > 0
|
||||
const isPartial = count > 0 && !isFull
|
||||
const fill = isOther
|
||||
? PALETTE.occupiedOther
|
||||
: isFull
|
||||
? PALETTE.full
|
||||
: isPartial
|
||||
? PALETTE.filled
|
||||
: PALETTE.empty
|
||||
const stroke = hovered
|
||||
? PALETTE.hover
|
||||
: isOther
|
||||
? PALETTE.occupiedOtherText
|
||||
: count > 0
|
||||
? PALETTE.filledBorder
|
||||
: PALETTE.emptyBorder
|
||||
const numberColor = isFull ? PALETTE.fullText : '#4E3149'
|
||||
|
||||
// Avatar dots – up to 4 per room; rest collapses to "+N".
|
||||
const visible = r.assignedParticipants.slice(0, 4)
|
||||
const overflow = count - visible.length
|
||||
const avatarR = 11
|
||||
const avatarGap = 2
|
||||
const totalW = visible.length * (avatarR * 2) + Math.max(0, visible.length - 1) * avatarGap
|
||||
const startX = x + w / 2 - totalW / 2 + avatarR
|
||||
const avatarY = y + h - avatarR - 8
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => onHover(r.roomId)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={hovered ? 2 : 1}
|
||||
rx={6}
|
||||
/>
|
||||
<text
|
||||
x={x + 8}
|
||||
y={y + 18}
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
fill={numberColor}
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
{r.number}
|
||||
</text>
|
||||
<text
|
||||
x={x + w - 8}
|
||||
y={y + 18}
|
||||
fontSize="9"
|
||||
textAnchor="end"
|
||||
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
{r.roomType}
|
||||
</text>
|
||||
{isOther ? (
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={y + h / 2 + 4}
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
fill={PALETTE.occupiedOtherText}
|
||||
fontStyle="italic"
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
—
|
||||
</text>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((p, idx) => {
|
||||
const cx = startX + idx * (avatarR * 2 + avatarGap)
|
||||
return (
|
||||
<g key={p.participantId}>
|
||||
<title>{p.name}</title>
|
||||
<circle cx={cx} cy={avatarY} r={avatarR} fill={tintFor(p.participantId)} stroke="#fff" strokeWidth={1.5} />
|
||||
<text
|
||||
x={cx}
|
||||
y={avatarY + 3}
|
||||
fontSize="9"
|
||||
textAnchor="middle"
|
||||
fill="#fff"
|
||||
fontWeight="700"
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
{initials(p.name)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<g>
|
||||
<circle
|
||||
cx={startX + visible.length * (avatarR * 2 + avatarGap)}
|
||||
cy={avatarY}
|
||||
r={avatarR}
|
||||
fill="#fff"
|
||||
stroke={PALETTE.filledBorder}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={startX + visible.length * (avatarR * 2 + avatarGap)}
|
||||
y={avatarY + 3}
|
||||
fontSize="9"
|
||||
textAnchor="middle"
|
||||
fill={PALETTE.filledBorder}
|
||||
fontWeight="700"
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
+{overflow}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x={x + 8}
|
||||
y={y + 34}
|
||||
fontSize="9"
|
||||
fill={isFull ? 'rgba(255,255,255,0.85)' : '#9B9499'}
|
||||
fontFamily="Open Sans, system-ui, sans-serif"
|
||||
>
|
||||
{count}/{r.beds}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
469
apps/app/src/components/SeminarhofChrome.tsx
Normal file
469
apps/app/src/components/SeminarhofChrome.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Box,
|
||||
Burger,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Menu,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale, type Locale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { ChevronDown, Facebook, Instagram, Mail, Phone } from 'lucide-react'
|
||||
import { BrandMark } from './Brand'
|
||||
|
||||
/**
|
||||
* Public-site chrome that mirrors seminarhof-drawehn.de (header + footer) so the
|
||||
* app's anonymous public pages (availability / events / enquiry) feel like one
|
||||
* cohesive website with the marketing site. Marketing destinations link out to
|
||||
* the live WordPress site; the app's own pages (events) stay internal.
|
||||
*
|
||||
* Nav labels are kept in German on purpose — they're the marketing site's
|
||||
* canonical labels (brand chrome), not app content. The DE/EN switcher toggles
|
||||
* the app's i18n language, which drives the page body, not these labels.
|
||||
*
|
||||
* Social/contact glyphs use lucide-react (the app's icon set) — the closest
|
||||
* equivalent to the live site's Font Awesome icons, not pixel-identical.
|
||||
*/
|
||||
|
||||
const SITE = 'https://seminarhof-drawehn.de'
|
||||
const PHONE_HREF = 'tel:+491724695132'
|
||||
const PHONE_LABEL = '+49 172 4695132'
|
||||
const EMAIL = 'info@seminarhof-drawehn.de'
|
||||
const TAGLINE = 'Retreats · Fortbildungen · Workation'
|
||||
|
||||
type NavChild = { label: string; href: string; internal?: boolean }
|
||||
type NavEntry = { label: string; href: string; internal?: boolean; children?: NavChild[] }
|
||||
|
||||
const ext = (path: string) => `${SITE}${path}`
|
||||
|
||||
const LEFT_NAV: NavEntry[] = [
|
||||
{
|
||||
label: 'Unser Seminarhof',
|
||||
href: ext('/seminarhof/'),
|
||||
children: [
|
||||
{ label: 'Seminarräume', href: ext('/seminarhof/') },
|
||||
{ label: 'Verpflegung', href: ext('/verpflegung/') },
|
||||
{ label: 'Unterkunft', href: ext('/unterkunft/') },
|
||||
{ label: 'Holistic Bodywork', href: ext('/holistic-bodywork/') },
|
||||
],
|
||||
},
|
||||
{ label: 'Anreise', href: ext('/anreise/') },
|
||||
{ label: 'Galerie', href: ext('/galerie/') },
|
||||
{ label: 'FAQ', href: ext('/faq/') },
|
||||
]
|
||||
|
||||
const RIGHT_NAV: NavEntry[] = [
|
||||
{ label: 'Veranstaltungen', href: '/events', internal: true },
|
||||
{
|
||||
label: 'Kontakt',
|
||||
href: ext('/kontakt/'),
|
||||
children: [
|
||||
{ label: 'Kontakt', href: ext('/kontakt/') },
|
||||
{ label: 'Über uns', href: ext('/ueber-uns/') },
|
||||
{ label: 'Gästebuch', href: ext('/gaestebuch/') },
|
||||
{ label: 'Jobs', href: ext('/jobs/') },
|
||||
],
|
||||
},
|
||||
{ label: 'Downloads', href: ext('/downloads/') },
|
||||
]
|
||||
|
||||
const FOOTER_COLUMNS: { heading: string; links: NavChild[] }[] = [
|
||||
{
|
||||
heading: 'Unser Seminarhof',
|
||||
links: [
|
||||
{ label: 'Seminarräume', href: ext('/seminarhof/') },
|
||||
{ label: 'Verpflegung', href: ext('/verpflegung/') },
|
||||
{ label: 'Unterkunft', href: ext('/unterkunft/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Angebote',
|
||||
links: [
|
||||
{ label: 'Veranstaltungen', href: '/events', internal: true },
|
||||
{ label: 'Holistic Bodywork', href: ext('/holistic-bodywork/') },
|
||||
{ label: 'Jobs', href: ext('/jobs/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Info',
|
||||
links: [
|
||||
{ label: 'FAQ', href: ext('/faq/') },
|
||||
{ label: 'Kontakt', href: ext('/kontakt/') },
|
||||
{ label: 'Downloads', href: ext('/downloads/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Rechtliches',
|
||||
links: [
|
||||
{ label: 'Impressum', href: ext('/impressum/') },
|
||||
{ label: 'Datenschutz', href: ext('/datenschutz/') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const NAV_ITEM_STYLE: React.CSSProperties = {
|
||||
color: '#ffffff',
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: '14.4px',
|
||||
fontWeight: 400,
|
||||
textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
/** A single top-level nav entry: plain external/internal link, or a hover menu. */
|
||||
function NavTopItem({ entry, onNavigate }: { entry: NavEntry; onNavigate?: () => void }) {
|
||||
if (entry.children) {
|
||||
return (
|
||||
<Menu trigger="hover" position="bottom-start" offset={14} withinPortal shadow="md" radius="sm">
|
||||
<Menu.Target>
|
||||
<Group gap={4} wrap="nowrap" style={{ ...NAV_ITEM_STYLE }}>
|
||||
<span>{entry.label}</span>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{ background: 'var(--brand-plum)', border: 'none', padding: 4 }}
|
||||
>
|
||||
{entry.children.map((c) => {
|
||||
const itemStyle = { color: '#ffffff', fontFamily: "'Open Sans', sans-serif", fontSize: '14px' }
|
||||
return c.internal ? (
|
||||
<Menu.Item key={c.label} component={Link} to={c.href} onClick={onNavigate} style={itemStyle}>
|
||||
{asI18n(`${c.label}`)}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item key={c.label} component="a" href={c.href} onClick={onNavigate} style={itemStyle}>
|
||||
{asI18n(`${c.label}`)}
|
||||
</Menu.Item>
|
||||
)
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
if (entry.internal) {
|
||||
return (
|
||||
<Link to={entry.href} onClick={onNavigate} style={NAV_ITEM_STYLE}>
|
||||
{entry.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={entry.href} onClick={onNavigate} style={NAV_ITEM_STYLE}>
|
||||
{entry.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageSwitcher() {
|
||||
const { setLocale } = useLocale()
|
||||
const current = (getLocale() || 'de').slice(0, 2).toUpperCase()
|
||||
return (
|
||||
<Menu trigger="hover" position="bottom-end" offset={14} withinPortal shadow="md" radius="sm">
|
||||
<Menu.Target>
|
||||
<Group gap={4} wrap="nowrap" style={NAV_ITEM_STYLE}>
|
||||
<span>{current}</span>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ background: 'var(--brand-plum)', border: 'none', padding: 4 }}>
|
||||
{['de', 'en'].map((lng) => (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => setLocale(lng as Locale)}
|
||||
style={{ color: '#ffffff', fontFamily: "'Open Sans', sans-serif", fontSize: '14px' }}
|
||||
>
|
||||
{asI18n(`${lng.toUpperCase()}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
function SocialIcons({ color = '#ffffff', size = 18 }: { color?: string; size?: number }) {
|
||||
const link = { color, display: 'flex' } as React.CSSProperties
|
||||
return (
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<a href={`mailto:${EMAIL}`} aria-label="E-Mail" style={link}>
|
||||
<Mail size={size} />
|
||||
</a>
|
||||
<a href="https://www.facebook.com/seminarhofdrawehn" aria-label="Facebook" target="_blank" rel="noreferrer" style={link}>
|
||||
<Facebook size={size} />
|
||||
</a>
|
||||
<a href="https://www.instagram.com/seminarhof_drawehn" aria-label="Instagram" target="_blank" rel="noreferrer" style={link}>
|
||||
<Instagram size={size} />
|
||||
</a>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
const BRAND_STYLE: React.CSSProperties = {
|
||||
fontFamily: "'Nunito', sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: '32px',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
export function SeminarhofHeader() {
|
||||
useLocale()
|
||||
const [opened, { toggle, close }] = useDisclosure(false)
|
||||
|
||||
return (
|
||||
// display:contents so the sticky nav row below isn't trapped in this short
|
||||
// header's containing block — its sticky context becomes the full-height
|
||||
// MarketingShell column, giving page-wide sticky behaviour.
|
||||
<Box component="header" style={{ display: 'contents' }}>
|
||||
{/* Top utility bar */}
|
||||
<Box style={{ background: 'var(--brand-plum-dark)' }} py={6} visibleFrom="md">
|
||||
<Container size="xl" w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text style={{ color: 'var(--brand-pink)', fontFamily: "'Open Sans', sans-serif", fontSize: 12 }}>
|
||||
{asI18n(`${TAGLINE}`)}
|
||||
</Text>
|
||||
<Group gap="lg" wrap="nowrap" style={{ color: 'var(--brand-pink)' }}>
|
||||
<a href={PHONE_HREF} style={{ color: 'var(--brand-pink)', display: 'flex', alignItems: 'center', gap: 6, fontFamily: "'Open Sans', sans-serif", fontSize: 12, textDecoration: 'none' }}>
|
||||
<Phone size={13} /> {PHONE_LABEL}
|
||||
</a>
|
||||
<a href={`mailto:${EMAIL}`} style={{ color: 'var(--brand-pink)', display: 'flex', alignItems: 'center', gap: 6, fontFamily: "'Open Sans', sans-serif", fontSize: 12, textDecoration: 'none' }}>
|
||||
<Mail size={13} /> {EMAIL}
|
||||
</a>
|
||||
<SocialIcons color="var(--brand-pink)" size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Sticky nav row */}
|
||||
<Box
|
||||
style={{
|
||||
background: 'rgba(136, 85, 127, 0.95)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
backdropFilter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
<Container size="xl" w="100%" style={{ position: 'relative' }}>
|
||||
<Group h={74} justify="space-between" wrap="nowrap">
|
||||
{/* Left group (desktop) */}
|
||||
<Group gap="lg" wrap="nowrap" visibleFrom="md">
|
||||
{LEFT_NAV.map((e) => (
|
||||
<NavTopItem key={e.label} entry={e} />
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{/* Mobile burger */}
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="md"
|
||||
color="#ffffff"
|
||||
size="sm"
|
||||
aria-label="Menu"
|
||||
/>
|
||||
|
||||
{/* Mobile brand — in-flow so it can't overlap the burger / switcher */}
|
||||
<Box hiddenFrom="md" style={{ flex: 1, minWidth: 0, padding: '0 8px' }}>
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
...BRAND_STYLE,
|
||||
fontSize: 20,
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
Seminarhof Drawehn
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{/* Right group (desktop) */}
|
||||
<Group gap="lg" wrap="nowrap" visibleFrom="md">
|
||||
{RIGHT_NAV.map((e) => (
|
||||
<NavTopItem key={e.label} entry={e} />
|
||||
))}
|
||||
<LanguageSwitcher />
|
||||
</Group>
|
||||
|
||||
{/* Mobile: language switcher stays visible on the right */}
|
||||
<Box hiddenFrom="md">
|
||||
<LanguageSwitcher />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Centered brand (desktop) — absolutely positioned so it stays
|
||||
centred regardless of the left/right group widths. On mobile the
|
||||
in-flow brand above is used instead, to avoid overlap. */}
|
||||
<Box
|
||||
visibleFrom="md"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
height: 74,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ ...BRAND_STYLE, pointerEvents: 'auto' }}>
|
||||
Seminarhof Drawehn
|
||||
</Link>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
size="80%"
|
||||
position="right"
|
||||
title={m.common__brand__name()}
|
||||
styles={{
|
||||
header: { background: 'var(--brand-plum)' },
|
||||
title: { color: '#ffffff', fontFamily: "'Nunito', sans-serif", fontWeight: 600 },
|
||||
close: { color: '#ffffff' },
|
||||
content: { background: 'var(--brand-plum)' },
|
||||
body: { background: 'var(--brand-plum)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" pt="md">
|
||||
{[...LEFT_NAV, ...RIGHT_NAV].map((e) =>
|
||||
e.children ? (
|
||||
<Stack key={e.label} gap={6}>
|
||||
<Text style={{ color: 'var(--brand-pink)', fontFamily: "'Open Sans', sans-serif", fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{asI18n(`${e.label}`)}
|
||||
</Text>
|
||||
{e.children.map((c) => (
|
||||
<DrawerLink key={c.label} child={c} onNavigate={close} indent />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<DrawerLink key={e.label} child={e} onNavigate={close} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerLink({
|
||||
child,
|
||||
onNavigate,
|
||||
indent,
|
||||
}: {
|
||||
child: NavChild
|
||||
onNavigate: () => void
|
||||
indent?: boolean
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
color: '#ffffff',
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: 16,
|
||||
textDecoration: 'none',
|
||||
paddingLeft: indent ? 12 : 0,
|
||||
}
|
||||
return child.internal ? (
|
||||
<Link to={child.href} onClick={onNavigate} style={style}>
|
||||
{child.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={child.href} onClick={onNavigate} style={style}>
|
||||
{child.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function SeminarhofFooter() {
|
||||
useLocale()
|
||||
const year = new Date().getFullYear()
|
||||
const footerLinkStyle: React.CSSProperties = {
|
||||
color: '#ffffff',
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: 15,
|
||||
textDecoration: 'none',
|
||||
}
|
||||
return (
|
||||
<Box component="footer" mt="auto">
|
||||
{/* Link columns band */}
|
||||
<Box style={{ background: 'var(--brand-plum)' }} py={48}>
|
||||
<Container size="xl" w="100%">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={40}>
|
||||
{FOOTER_COLUMNS.map((col) => (
|
||||
<Stack key={col.heading} gap="sm">
|
||||
<Text
|
||||
style={{
|
||||
color: 'var(--brand-pink)',
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: 14.4,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{asI18n(`${col.heading}`)}
|
||||
</Text>
|
||||
{col.links.map((l) =>
|
||||
l.internal ? (
|
||||
<Link key={l.label} to={l.href} style={footerLinkStyle}>
|
||||
{l.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a key={l.label} href={l.href} style={footerLinkStyle}>
|
||||
{l.label}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Copyright band */}
|
||||
<Box style={{ background: 'var(--brand-plum-dark)' }} py={20}>
|
||||
<Container size="xl" w="100%" style={{ position: 'relative' }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text style={{ color: '#ffffff', fontFamily: "'Nunito', sans-serif", fontSize: 22, fontWeight: 400 }}>
|
||||
{asI18n(`© ${year} `)}{m.common__brand__name()}
|
||||
</Text>
|
||||
<SocialIcons color="#ffffff" size={20} />
|
||||
</Group>
|
||||
{/* Centered wheel logo */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
<BrandMark variant="white" size={56} />
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
57
apps/app/src/components/StatusPill.tsx
Normal file
57
apps/app/src/components/StatusPill.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Box, Group, Text } from '@pikku/mantine/core'
|
||||
import { bookingStatus, invoiceStatus } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import {
|
||||
BOOKING_STATUS,
|
||||
INVOICE_STATUS,
|
||||
type BookingStatus,
|
||||
type InvoiceStatus,
|
||||
} from '../lib/status'
|
||||
|
||||
type Variant =
|
||||
| { kind: 'booking'; status: BookingStatus }
|
||||
| { kind: 'invoice'; status: InvoiceStatus }
|
||||
|
||||
export function StatusPill(props: Variant & { size?: 'sm' | 'xs' }) {
|
||||
useLocale()
|
||||
const size = props.size ?? 'xs'
|
||||
const palette =
|
||||
props.kind === 'booking'
|
||||
? BOOKING_STATUS[props.status]
|
||||
: INVOICE_STATUS[props.status]
|
||||
const label =
|
||||
props.kind === 'booking'
|
||||
? bookingStatus[props.status]()
|
||||
: invoiceStatus[props.status]()
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
background: palette.bg,
|
||||
color: palette.fg,
|
||||
padding: size === 'xs' ? '2px 8px' : '4px 10px',
|
||||
borderRadius: 999,
|
||||
fontSize: size === 'xs' ? 11 : 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-flex',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: palette.dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text component="span" inherit>{label}</Text>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
40
apps/app/src/direction.ts
Normal file
40
apps/app/src/direction.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Document direction (LTR / RTL).
|
||||
*
|
||||
* v1 is EN-only but every widget and page must render correctly under
|
||||
* RTL — the plumbing is always active. Precedence:
|
||||
*
|
||||
* 1. `?dir=rtl` query param (dev override — test any route in RTL)
|
||||
* 2. localStorage `fabric-dir` (user preference across reloads)
|
||||
* 3. `document.documentElement.dir`
|
||||
* 4. 'ltr' fallback
|
||||
*
|
||||
* Locale-bound direction (Arabic / Hebrew / Persian → RTL) is post-v1.
|
||||
* Shape supports it — call `setDirection('rtl')` when a locale loads.
|
||||
*/
|
||||
|
||||
export type Direction = 'ltr' | 'rtl'
|
||||
|
||||
const STORAGE_KEY = 'fabric-dir'
|
||||
|
||||
export function readInitialDirection(): Direction {
|
||||
if (typeof window === 'undefined') return 'ltr'
|
||||
|
||||
const query = new URLSearchParams(window.location.search).get('dir')
|
||||
if (query === 'rtl' || query === 'ltr') return query
|
||||
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'rtl' || stored === 'ltr') return stored
|
||||
|
||||
const docDir = window.document.documentElement.dir
|
||||
if (docDir === 'rtl' || docDir === 'ltr') return docDir
|
||||
|
||||
return 'ltr'
|
||||
}
|
||||
|
||||
export function applyDirection(dir: Direction) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, dir)
|
||||
window.document.documentElement.dir = dir
|
||||
}
|
||||
}
|
||||
101
apps/app/src/i18n/config.ts
Normal file
101
apps/app/src/i18n/config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
|
||||
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
|
||||
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
|
||||
// message functions (`m`).
|
||||
//
|
||||
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
|
||||
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
|
||||
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js'
|
||||
|
||||
export const supportedLocales = locales
|
||||
export const defaultLocale = baseLocale
|
||||
export type Locale = (typeof locales)[number]
|
||||
// `getLocale()` reads the active locale (via the overwrite below) — used by
|
||||
// pages that format dates/values for the current language.
|
||||
export { getLocale }
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'seminarhof.language'
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
|
||||
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
const normalizeLanguage = (value: string | null | undefined): Locale | undefined => {
|
||||
const short = value?.slice(0, 2).toLowerCase()
|
||||
return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined
|
||||
}
|
||||
|
||||
// Initial locale: persisted choice → browser → <html lang> → base. (Ported from
|
||||
// the old i18next init; germantax has no in-app switcher, locale is read once.)
|
||||
export function detectInitialLocale(): Locale {
|
||||
if (typeof window === 'undefined') return defaultLocale
|
||||
return (
|
||||
normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ??
|
||||
normalizeLanguage(window.navigator.language) ??
|
||||
normalizeLanguage(window.document.documentElement.lang) ??
|
||||
defaultLocale
|
||||
)
|
||||
}
|
||||
|
||||
// ── reactive locale store ────────────────────────────────────────────────────
|
||||
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
|
||||
// it to a tiny external store: `getLocale` reads `activeLocale`; components
|
||||
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
|
||||
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
|
||||
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
|
||||
let activeLocale: Locale = defaultLocale
|
||||
const listeners = new Set<() => void>()
|
||||
overwriteGetLocale(() => activeLocale)
|
||||
|
||||
export function setActiveLocale(next: Locale): void {
|
||||
if (next === activeLocale) return
|
||||
activeLocale = next
|
||||
// Persist + reflect on <html lang> (ported from the old i18next languageChanged
|
||||
// handler), so a reload restores the choice.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next)
|
||||
window.document.documentElement.lang = next
|
||||
}
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// Resolve the initial locale once at startup (import side-effect, matching the
|
||||
// old i18n.ts init). Importing this module from the app entry runs it.
|
||||
activeLocale = detectInitialLocale()
|
||||
|
||||
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
|
||||
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
|
||||
// used to live; it also returns the active locale + direction for components
|
||||
// that need them (e.g. the language switcher).
|
||||
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
|
||||
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
|
||||
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
|
||||
}
|
||||
|
||||
// ── i18n-debug masking ───────────────────────────────────────────────────────
|
||||
// When enabled, every *translated* string is masked to block glyphs (█) so any
|
||||
// readable text left on screen is text that never went through a message (a
|
||||
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
|
||||
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
|
||||
export function isI18nDebug(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||
if (typeof window === 'undefined') return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||
}
|
||||
|
||||
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
|
||||
export function maskI18n(s: string): string {
|
||||
return isI18nDebug() ? s.replace(/\S/g, '█') : s
|
||||
}
|
||||
116
apps/app/src/i18n/i18n-enum.gen.ts
Normal file
116
apps/app/src/i18n/i18n-enum.gen.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// AUTO-GENERATED by @pikku/paraglide — do not edit.
|
||||
// Source of truth: the `enum__*` keys in the message catalog.
|
||||
import { m } from './messages.js'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
|
||||
/** A message accessor — call it at render time so the label tracks the active locale. */
|
||||
export type I18nMessage = () => I18nString
|
||||
/** A static, exhaustive map from an enum member to its branded i18n label. */
|
||||
export type EnumI18n<E extends string> = Record<E, I18nMessage>
|
||||
|
||||
export const role = {
|
||||
client: m.enum__role__client,
|
||||
admin: m.enum__role__admin,
|
||||
owner: m.enum__role__owner,
|
||||
} satisfies EnumI18n<"client" | "admin" | "owner">
|
||||
export type RoleKey = keyof typeof role
|
||||
|
||||
export const participantKind = {
|
||||
overnight: m.enum__participant_kind__overnight,
|
||||
day_guest: m.enum__participant_kind__day_guest,
|
||||
} satisfies EnumI18n<"overnight" | "day_guest">
|
||||
export type ParticipantKindKey = keyof typeof participantKind
|
||||
|
||||
export const dietary = {
|
||||
omnivore: m.enum__dietary__omnivore,
|
||||
vegetarian: m.enum__dietary__vegetarian,
|
||||
vegan: m.enum__dietary__vegan,
|
||||
gluten_free: m.enum__dietary__gluten_free,
|
||||
lactose_free: m.enum__dietary__lactose_free,
|
||||
pescatarian: m.enum__dietary__pescatarian,
|
||||
unknown: m.enum__dietary__unknown,
|
||||
} satisfies EnumI18n<"omnivore" | "vegetarian" | "vegan" | "gluten_free" | "lactose_free" | "pescatarian" | "unknown">
|
||||
export type DietaryKey = keyof typeof dietary
|
||||
|
||||
export const severity = {
|
||||
mild: m.enum__severity__mild,
|
||||
moderate: m.enum__severity__moderate,
|
||||
severe: m.enum__severity__severe,
|
||||
standard: m.enum__severity__standard,
|
||||
separate_prep: m.enum__severity__separate_prep,
|
||||
} satisfies EnumI18n<"mild" | "moderate" | "severe" | "standard" | "separate_prep">
|
||||
export type SeverityKey = keyof typeof severity
|
||||
|
||||
export const enquiryStatus = {
|
||||
pending: m.enum__enquiry_status__pending,
|
||||
approved: m.enum__enquiry_status__approved,
|
||||
declined: m.enum__enquiry_status__declined,
|
||||
waitlisted: m.enum__enquiry_status__waitlisted,
|
||||
} satisfies EnumI18n<"pending" | "approved" | "declined" | "waitlisted">
|
||||
export type EnquiryStatusKey = keyof typeof enquiryStatus
|
||||
|
||||
export const invoiceKind = {
|
||||
deposit: m.enum__invoice_kind__deposit,
|
||||
final: m.enum__invoice_kind__final,
|
||||
} satisfies EnumI18n<"deposit" | "final">
|
||||
export type InvoiceKindKey = keyof typeof invoiceKind
|
||||
|
||||
export const invoiceStatus = {
|
||||
outstanding: m.enum__invoice_status__outstanding,
|
||||
paid: m.enum__invoice_status__paid,
|
||||
cancelled: m.enum__invoice_status__cancelled,
|
||||
} satisfies EnumI18n<"outstanding" | "paid" | "cancelled">
|
||||
export type InvoiceStatusKey = keyof typeof invoiceStatus
|
||||
|
||||
export const emailKind = {
|
||||
send_contract_and_deposit_email: m.enum__email_kind__send_contract_and_deposit_email,
|
||||
send_deposit_reminder_email: m.enum__email_kind__send_deposit_reminder_email,
|
||||
send_reservation_confirmed_email: m.enum__email_kind__send_reservation_confirmed_email,
|
||||
send_booking_confirmed_email: m.enum__email_kind__send_booking_confirmed_email,
|
||||
send_cancellation_email: m.enum__email_kind__send_cancellation_email,
|
||||
send_enquiry_declined_email: m.enum__email_kind__send_enquiry_declined_email,
|
||||
} satisfies EnumI18n<"send_contract_and_deposit_email" | "send_deposit_reminder_email" | "send_reservation_confirmed_email" | "send_booking_confirmed_email" | "send_cancellation_email" | "send_enquiry_declined_email">
|
||||
export type EmailKindKey = keyof typeof emailKind
|
||||
|
||||
export const flagReason = {
|
||||
deposit_overdue: m.enum__flag_reason__deposit_overdue,
|
||||
short_window: m.enum__flag_reason__short_window,
|
||||
contract_declined: m.enum__flag_reason__contract_declined,
|
||||
} satisfies EnumI18n<"deposit_overdue" | "short_window" | "contract_declined">
|
||||
export type FlagReasonKey = keyof typeof flagReason
|
||||
|
||||
export const building = {
|
||||
gaestehaus: m.enum__building__gaestehaus,
|
||||
haupthaus: m.enum__building__haupthaus,
|
||||
} satisfies EnumI18n<"gaestehaus" | "haupthaus">
|
||||
export type BuildingKey = keyof typeof building
|
||||
|
||||
export const floor = {
|
||||
eg: m.enum__floor__eg,
|
||||
og: m.enum__floor__og,
|
||||
} satisfies EnumI18n<"eg" | "og">
|
||||
export type FloorKey = keyof typeof floor
|
||||
|
||||
export const allergen = {
|
||||
gluten_strict: m.enum__allergen__gluten_strict,
|
||||
nuts: m.enum__allergen__nuts,
|
||||
soy: m.enum__allergen__soy,
|
||||
dairy: m.enum__allergen__dairy,
|
||||
egg: m.enum__allergen__egg,
|
||||
fish: m.enum__allergen__fish,
|
||||
shellfish: m.enum__allergen__shellfish,
|
||||
sesame: m.enum__allergen__sesame,
|
||||
celery: m.enum__allergen__celery,
|
||||
mustard: m.enum__allergen__mustard,
|
||||
other: m.enum__allergen__other,
|
||||
} satisfies EnumI18n<"gluten_strict" | "nuts" | "soy" | "dairy" | "egg" | "fish" | "shellfish" | "sesame" | "celery" | "mustard" | "other">
|
||||
export type AllergenKey = keyof typeof allergen
|
||||
|
||||
export const bookingStatus = {
|
||||
enquiry: m.enum__booking_status__enquiry,
|
||||
reserved: m.enum__booking_status__reserved,
|
||||
confirmed: m.enum__booking_status__confirmed,
|
||||
ended: m.enum__booking_status__ended,
|
||||
cancelled: m.enum__booking_status__cancelled,
|
||||
} satisfies EnumI18n<"enquiry" | "reserved" | "confirmed" | "ended" | "cancelled">
|
||||
export type BookingStatusKey = keyof typeof bookingStatus
|
||||
24
apps/app/src/i18n/messages.ts
Normal file
24
apps/app/src/i18n/messages.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Typed message functions for the app. `m.landing_title()` returns a branded
|
||||
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
|
||||
// `t('landing.title')` used to — no call-site boilerplate.
|
||||
//
|
||||
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
|
||||
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
|
||||
// per-message tree-shaking — fine here: locale files are KB and the app bundled
|
||||
// all of en.json under i18next anyway.
|
||||
import { m as _m } from '../paraglide/messages.js'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { maskI18n } from './config.js'
|
||||
|
||||
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
|
||||
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
|
||||
|
||||
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
|
||||
const _wrapped: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(_raw)) {
|
||||
const fn = _raw[key]
|
||||
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
|
||||
}
|
||||
|
||||
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
|
||||
export const m = _wrapped as unknown as Branded<typeof _m>
|
||||
18
apps/app/src/lib/auth.ts
Normal file
18
apps/app/src/lib/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { apiUrl } from './env'
|
||||
|
||||
// Better Auth mounts at /api/auth (default basePath); the client appends the
|
||||
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
|
||||
// emits /api/sign-in/email and 404s on both sandbox and deploy.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: `${apiUrl()}/auth`,
|
||||
})
|
||||
|
||||
export async function signIn(email: string, password: string): Promise<void> {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) throw new Error('Invalid credentials')
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await authClient.signOut()
|
||||
}
|
||||
24
apps/app/src/lib/dates.ts
Normal file
24
apps/app/src/lib/dates.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Placeholder shown for missing dates (an enquiry may not have dates yet).
|
||||
const NO_DATE = '—'
|
||||
|
||||
export function fmtDate(d: Date | null | undefined, locale: string): string {
|
||||
if (!d) return NO_DATE
|
||||
return d.toLocaleDateString(locale.startsWith('de') ? 'de-DE' : 'en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function fmtDateShort(d: Date | null | undefined, locale: string): string {
|
||||
if (!d) return NO_DATE
|
||||
return d.toLocaleDateString(locale.startsWith('de') ? 'de-DE' : 'en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
export function toDateInputStr(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
24
apps/app/src/lib/env.ts
Normal file
24
apps/app/src/lib/env.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Endpoints come from env, never hardcoded.
|
||||
//
|
||||
// In local dev VITE_API_URL is set at build time and Vite replaces the
|
||||
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
|
||||
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
|
||||
// in the bundle. We fall back to the current origin + /api, which is correct
|
||||
// because the dispatcher routes every /api/* path to the API on the same host.
|
||||
export function apiUrl(): string {
|
||||
if (typeof window !== 'undefined' && (window as any).__E2E_API_URL) {
|
||||
// E2E talks directly to the backend (no vite proxy). Better Auth is mounted
|
||||
// at /api/auth there, while pikku routes live at the root — so the auth
|
||||
// client must add the /api prefix that __E2E_API_URL (bare host) lacks.
|
||||
return `${(window as any).__E2E_API_URL as string}/api`
|
||||
}
|
||||
if (import.meta.env.SSR) {
|
||||
// SSR context: the auth client is only consumed in the browser. Return the
|
||||
// build-time var when available or a placeholder so SSR never throws.
|
||||
return import.meta.env.VITE_API_URL ?? '/__api'
|
||||
}
|
||||
// Client: build-time var (local dev) or derive from the current origin.
|
||||
// Better Auth's client requires an absolute base URL, so never return a bare
|
||||
// relative '/api' here — '/api/auth' throws "Invalid base URL".
|
||||
return import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`
|
||||
}
|
||||
35
apps/app/src/lib/status.ts
Normal file
35
apps/app/src/lib/status.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Brand-aligned status palettes. Single source of truth for booking, invoice
|
||||
// and room state colours. Each entry: bg (badge background), fg (text colour)
|
||||
// and dot (8 px leading dot). Hex codes are checked for ≥4.5:1 on bg.
|
||||
|
||||
export type BookingStatus =
|
||||
| 'enquiry'
|
||||
| 'reserved'
|
||||
| 'confirmed'
|
||||
| 'ended'
|
||||
| 'cancelled'
|
||||
|
||||
export const BOOKING_STATUS: Record<BookingStatus, { bg: string; fg: string; dot: string }> = {
|
||||
enquiry: { bg: '#F7EEF5', fg: '#583651', dot: '#BC7AA6' },
|
||||
reserved: { bg: '#FFEDDC', fg: '#8A4A1A', dot: '#FFBC7D' },
|
||||
confirmed: { bg: '#E9D4E2', fg: '#4E3149', dot: '#88557F' },
|
||||
ended: { bg: '#EDEDED', fg: '#4A4A4A', dot: '#989499' },
|
||||
cancelled: { bg: '#F2F2F2', fg: '#6B6B6B', dot: '#B5B5B5' },
|
||||
}
|
||||
|
||||
/** Valid transitions per status (client-side, for CTA buttons). Enforced server-side too. */
|
||||
export const BOOKING_TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
||||
enquiry: ['reserved', 'cancelled'],
|
||||
reserved: ['confirmed', 'cancelled'],
|
||||
confirmed: ['ended', 'cancelled'],
|
||||
ended: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
export type InvoiceStatus = 'outstanding' | 'paid' | 'cancelled'
|
||||
|
||||
export const INVOICE_STATUS: Record<InvoiceStatus, { bg: string; fg: string; dot: string }> = {
|
||||
outstanding: { bg: '#FFF8E0', fg: '#806600', dot: '#FFCE00' },
|
||||
paid: { bg: '#E9D4E2', fg: '#4E3149', dot: '#88557F' },
|
||||
cancelled: { bg: '#F2F2F2', fg: '#6B6B6B', dot: '#B5B5B5' },
|
||||
}
|
||||
98
apps/app/src/pages/__root.tsx
Normal file
98
apps/app/src/pages/__root.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
} from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MantineProvider, Box } from '@pikku/mantine/core'
|
||||
import { PikkuProvider, createPikku } from '@pikku/react'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import '@mantine/core/styles.css'
|
||||
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
|
||||
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
|
||||
import { theme } from '../theme'
|
||||
import '../theme.css'
|
||||
import '../i18n/config'
|
||||
import { readInitialDirection } from '../direction'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__E2E_API_URL?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'Seminarhof Drawehn' },
|
||||
],
|
||||
}),
|
||||
component: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument() {
|
||||
useLocale()
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 10_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: { retry: 0 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const runtimeApiUrl =
|
||||
typeof window !== 'undefined' ? window.__E2E_API_URL : undefined
|
||||
const [pikku] = useState(() =>
|
||||
createPikku(PikkuFetch, PikkuRPC, {
|
||||
transformDate: true,
|
||||
serverUrl: runtimeApiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={(getLocale() ?? 'de').slice(0, 2)}
|
||||
dir={readInitialDirection()}
|
||||
data-app-hydrated="false"
|
||||
>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<HydrationReady />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider theme={theme}>
|
||||
<PikkuProvider pikku={pikku}>
|
||||
<Box
|
||||
mih="100vh"
|
||||
bg="var(--mantine-color-body)"
|
||||
c="var(--mantine-color-text)"
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</PikkuProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
function HydrationReady() {
|
||||
useEffect(() => {
|
||||
window.document.documentElement.dataset.appHydrated = 'true'
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
1036
apps/app/src/pages/admin.bookings.$bookingId.tsx
Normal file
1036
apps/app/src/pages/admin.bookings.$bookingId.tsx
Normal file
File diff suppressed because it is too large
Load Diff
260
apps/app/src/pages/admin.bookings.index.tsx
Normal file
260
apps/app/src/pages/admin.bookings.index.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { bookingStatus } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Search } from 'lucide-react'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { StatusPill } from '../components/StatusPill'
|
||||
import { usePageTitle } from '../components/AppLayout'
|
||||
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
|
||||
import { fmtDateShort } from '../lib/dates'
|
||||
|
||||
const STATUS_KEYS: BookingStatus[] = [
|
||||
'enquiry',
|
||||
'reserved',
|
||||
'confirmed',
|
||||
'ended',
|
||||
'cancelled',
|
||||
]
|
||||
|
||||
function StatusDot({ color }: { color: string }) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: color,
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminBookingsInner() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
|
||||
usePageTitle(m.common__pages__admin__bookings__title())
|
||||
|
||||
const [years, setYears] = useState<string[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [halfHouseOnly, setHalfHouseOnly] = useState(false)
|
||||
|
||||
const queryArgs = useMemo(
|
||||
() => ({
|
||||
venueSlug: 'drawehn',
|
||||
years: years.length ? years.map(Number) : undefined,
|
||||
status: statusFilter.length ? statusFilter : undefined,
|
||||
search: search.trim() || undefined,
|
||||
halfHouseOnly: halfHouseOnly || undefined,
|
||||
}),
|
||||
[years, statusFilter, search, halfHouseOnly],
|
||||
)
|
||||
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase(getLocale())
|
||||
|
||||
const { data, isLoading, isFetching, error } = usePikkuQuery(
|
||||
'getAdminBookings',
|
||||
queryArgs,
|
||||
{ placeholderData: keepPreviousData },
|
||||
)
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
STATUS_KEYS.map((s) => ({
|
||||
value: s,
|
||||
label: bookingStatus[s](),
|
||||
})),
|
||||
[getLocale()],
|
||||
)
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
}
|
||||
if (error && !data) {
|
||||
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
|
||||
}
|
||||
if (!data) return null
|
||||
|
||||
const bookings = [...data.bookings].sort((a, b) => {
|
||||
if (!normalizedSearch) return 0
|
||||
|
||||
const rank = (value: string | null | undefined) => {
|
||||
const text = value?.toLocaleLowerCase(getLocale()) ?? ''
|
||||
if (text === normalizedSearch) return 0
|
||||
if (text.startsWith(normalizedSearch)) return 1
|
||||
if (text.includes(normalizedSearch)) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
const aRank = Math.min(rank(a.eventName), rank(a.clientName))
|
||||
const bRank = Math.min(rank(b.eventName), rank(b.clientName))
|
||||
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
return a.eventName.localeCompare(b.eventName, getLocale())
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex="1 1 220px"
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder={m.common__pages__admin__bookings__search_placeholder()}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<MultiSelect
|
||||
flex="0 1 200px"
|
||||
clearable
|
||||
placeholder={
|
||||
years.length ? undefined : m.common__pages__admin__bookings__filters__year()
|
||||
}
|
||||
data={data.yearOptions.map((y) => String(y))}
|
||||
value={years}
|
||||
onChange={setYears}
|
||||
/>
|
||||
<MultiSelect
|
||||
flex="1 1 240px"
|
||||
clearable
|
||||
placeholder={
|
||||
statusFilter.length
|
||||
? undefined
|
||||
: m.common__pages__admin__bookings__filters__status()
|
||||
}
|
||||
data={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<StatusDot color={BOOKING_STATUS[option.value as BookingStatus].dot} />
|
||||
<span>{option.label}</span>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
<Checkbox
|
||||
color="plum"
|
||||
label={m.common__pages__admin__bookings__filters__half_house_only()}
|
||||
checked={halfHouseOnly}
|
||||
onChange={(e) => setHalfHouseOnly(e.currentTarget.checked)}
|
||||
styles={{ root: { alignSelf: 'center' } }}
|
||||
/>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/enquiry"
|
||||
color="plum"
|
||||
variant="filled"
|
||||
ml="auto"
|
||||
>
|
||||
{m.common__pages__admin__bookings__new_booking()}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__admin__bookings__stats__showing({ count: data.totalCount })}
|
||||
</Text>
|
||||
{isFetching && <Loader size="xs" color="plum" />}
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p={0}>
|
||||
{bookings.length === 0 ? (
|
||||
<Box p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.common__pages__admin__bookings__empty()}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__dates()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__course()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__client()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__status()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__persons()}</Table.Th>
|
||||
<Table.Th ta="center">{m.common__pages__admin__bookings__cols__half_house()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__deposit()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__bookings__cols__final()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr
|
||||
key={b.bookingId}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: b.bookingId } })
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm">{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}</Text>
|
||||
<Text size="xs" c="dimmed">{asI18n(`→ ${fmtDateShort(b.endDate, getLocale())}`)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{asI18n(`${b.eventName}`)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{b.isStammgruppe && (
|
||||
<Text size="xs" c="yellow" title={m.common__pages__admin__bookings__cols__regular_group()}>{asI18n('★')}</Text>
|
||||
)}
|
||||
{asI18n(`${b.clientName}`)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill kind="booking" status={b.status as BookingStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.participantCount} / {b.expectedPersons ?? '?'}
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">{b.halfHouse ? '½' : ''}</Table.Td>
|
||||
<Table.Td>
|
||||
{!b.hasDeposit
|
||||
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
|
||||
: <Badge size="xs" color={b.depositPaid ? 'green' : 'orange'} variant="light">
|
||||
{(b.depositPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
|
||||
</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!b.hasFinal
|
||||
? <Text size="xs" c="dimmed">{m.common__pages__admin__bookings__deposit__missing()}</Text>
|
||||
: <Badge size="xs" color={b.finalPaid ? 'green' : 'orange'} variant="light">
|
||||
{(b.finalPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
|
||||
</Badge>}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/admin/bookings/')({
|
||||
component: AdminBookingsInner,
|
||||
})
|
||||
10
apps/app/src/pages/admin.bookings.tsx
Normal file
10
apps/app/src/pages/admin.bookings.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { RequireAuth } from '../auth'
|
||||
|
||||
export const Route = createFileRoute('/admin/bookings')({
|
||||
component: () => (
|
||||
<RequireAuth roles={['admin', 'owner']}>
|
||||
<Outlet />
|
||||
</RequireAuth>
|
||||
),
|
||||
})
|
||||
564
apps/app/src/pages/admin.enquiries.tsx
Normal file
564
apps/app/src/pages/admin.enquiries.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { enquiryStatus } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDebouncedValue } from '@mantine/hooks'
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@pikku/mantine/core'
|
||||
import { Check, Clock, X } from 'lucide-react'
|
||||
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { usePageTitle } from '../components/AppLayout'
|
||||
import { RequireAuth } from '../auth'
|
||||
import { fmtDate, fmtDateShort } from '../lib/dates'
|
||||
|
||||
const STATUS_KEYS = ['pending', 'approved', 'declined'] as const
|
||||
type EnquiryStatus = (typeof STATUS_KEYS)[number]
|
||||
|
||||
type EnquiryDateOption = {
|
||||
optionId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
type Enquiry = {
|
||||
enquiryId: string
|
||||
contactName: string | null
|
||||
contactEmail: string
|
||||
contactPhone: string | null
|
||||
website: string | null
|
||||
eventName: string
|
||||
eventOutline: string | null
|
||||
description: string | null
|
||||
dateOptions: EnquiryDateOption[]
|
||||
expectedPersons: number | null
|
||||
halfHouse: boolean
|
||||
notes: string | null
|
||||
status: string
|
||||
waitlistedAt: Date | null
|
||||
approvedAt: Date | null
|
||||
declinedAt: Date | null
|
||||
createdAt: Date
|
||||
bookingId: string | null
|
||||
}
|
||||
|
||||
// The SDK's date reviver (transform-date.js) turns any 'YYYY-MM-DD…' response
|
||||
// string into a Date — including these option dates, despite their string type.
|
||||
// Normalize back to a plain 'YYYY-MM-DD' string so the date inputs and equality
|
||||
// checks below work, and parse at local midnight for display.
|
||||
const toYmd = (v: string | Date): string =>
|
||||
v instanceof Date ? v.toISOString().slice(0, 10) : v
|
||||
const optDate = (s: string) => new Date(`${s}T00:00:00`)
|
||||
|
||||
function StatusBadge({ enquiry }: { enquiry: Enquiry }) {
|
||||
useLocale()
|
||||
const color =
|
||||
enquiry.status === 'approved' ? 'green' : enquiry.status === 'declined' ? 'red' : 'blue'
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge color={color} variant="light" size="sm">
|
||||
{enquiryStatus[enquiry.status as keyof typeof enquiryStatus]()}
|
||||
</Badge>
|
||||
{enquiry.status === 'pending' && enquiry.waitlistedAt && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{enquiryStatus.waitlisted()}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminEnquiriesInner() {
|
||||
useLocale()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
usePageTitle(m.common__pages__admin__enquiries__title())
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>(['pending'])
|
||||
|
||||
const queryArgs = useMemo(
|
||||
() => ({
|
||||
venueSlug: 'drawehn',
|
||||
status: statusFilter.length ? statusFilter : undefined,
|
||||
}),
|
||||
[statusFilter],
|
||||
)
|
||||
|
||||
const { data, isLoading, isFetching, error } = usePikkuQuery('getEnquiries', queryArgs, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['getEnquiries'] })
|
||||
|
||||
// Which enquiry has an open transition modal, and which kind.
|
||||
const [modal, setModal] = useState<{ kind: 'approve' | 'waitlist' | 'decline'; enquiry: Enquiry } | null>(null)
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
STATUS_KEYS.map((s) => ({
|
||||
value: s,
|
||||
label: enquiryStatus[s](),
|
||||
})),
|
||||
[getLocale()],
|
||||
)
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
}
|
||||
if (error && !data) {
|
||||
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
|
||||
}
|
||||
if (!data) return null
|
||||
|
||||
const enquiries = (data.enquiries as Enquiry[]).map((e) => ({
|
||||
...e,
|
||||
dateOptions: e.dateOptions.map((o) => ({
|
||||
...o,
|
||||
startDate: toYmd(o.startDate),
|
||||
endDate: toYmd(o.endDate),
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<MultiSelect
|
||||
flex="1 1 240px"
|
||||
clearable
|
||||
placeholder={statusFilter.length ? undefined : m.common__pages__admin__enquiries__filters__status()}
|
||||
data={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__admin__enquiries__stats__showing({ count: enquiries.length })}
|
||||
</Text>
|
||||
{isFetching && <Loader size="xs" color="plum" />}
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p={0}>
|
||||
{enquiries.length === 0 ? (
|
||||
<Box p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.common__pages__admin__enquiries__empty()}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__received()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__event()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__contact()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__dates()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__persons()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__status()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__admin__enquiries__cols__actions()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{enquiries.map((e) => (
|
||||
<Table.Tr key={e.enquiryId}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{asI18n(`${fmtDateShort(e.createdAt, getLocale())}`)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{asI18n(`${e.eventName}`)}</Text>
|
||||
{e.eventOutline && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${e.eventOutline}`)}</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{asI18n(`${e.contactName ?? '—'}`)}</Text>
|
||||
<Text size="xs" c="dimmed">{asI18n(`${e.contactEmail}`)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{e.dateOptions.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">{m.common__pages__admin__enquiries__no_dates()}</Text>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
{e.dateOptions.map((o, i) => (
|
||||
<Text key={o.optionId} size="sm" c={i === 0 ? undefined : 'dimmed'}>
|
||||
{asI18n(`${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{e.expectedPersons ?? '—'}</Table.Td>
|
||||
<Table.Td><StatusBadge enquiry={e} /></Table.Td>
|
||||
<Table.Td>
|
||||
{e.status === 'pending' ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<Check size={14} />}
|
||||
onClick={() => setModal({ kind: 'approve', enquiry: e })}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__approve()}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<Clock size={14} />}
|
||||
onClick={() => setModal({ kind: 'waitlist', enquiry: e })}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__waitlist()}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setModal({ kind: 'decline', enquiry: e })}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__decline()}
|
||||
</Button>
|
||||
</Group>
|
||||
) : e.status === 'approved' && e.bookingId ? (
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId: e.bookingId! } })
|
||||
}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__view_booking()}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{modal?.kind === 'approve' && (
|
||||
<ApproveModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
||||
)}
|
||||
{modal?.kind === 'waitlist' && (
|
||||
<WaitlistModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
||||
)}
|
||||
{modal?.kind === 'decline' && (
|
||||
<DeclineModal enquiry={modal.enquiry} onClose={() => setModal(null)} onDone={() => { setModal(null); invalidate() }} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Approve modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
||||
useLocale()
|
||||
|
||||
const [mode, setMode] = useState<'existing' | 'new'>('existing')
|
||||
// Prefill from the visitor's preferred (first) date option; the admin can
|
||||
// pick a different option or override the inputs entirely.
|
||||
const topOption = enquiry.dateOptions[0]
|
||||
const [startDate, setStartDate] = useState(topOption?.startDate ?? '')
|
||||
const [endDate, setEndDate] = useState(topOption?.endDate ?? '')
|
||||
|
||||
// Existing-client search (server-side via getClients).
|
||||
const [clientId, setClientId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250)
|
||||
const { data: clientData, isFetching: clientsFetching } = usePikkuQuery(
|
||||
'getClients',
|
||||
{ venueSlug: 'drawehn', q: debouncedSearch.trim() || undefined },
|
||||
{ placeholderData: keepPreviousData, enabled: mode === 'existing' },
|
||||
)
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
(clientData?.clients ?? []).map((c) => ({
|
||||
value: c.clientId,
|
||||
label: c.name ? `${c.name}${c.contactEmail ? ` · ${c.contactEmail}` : ''}` : (c.contactEmail ?? c.clientId),
|
||||
})),
|
||||
[clientData],
|
||||
)
|
||||
|
||||
// New-client fields, pre-filled from the enquiry contact.
|
||||
const [name, setName] = useState(enquiry.contactName ?? '')
|
||||
const [email, setEmail] = useState(enquiry.contactEmail)
|
||||
const [phone, setPhone] = useState(enquiry.contactPhone ?? '')
|
||||
const [website, setWebsite] = useState(enquiry.website ?? '')
|
||||
const [billingAddress, setBillingAddress] = useState('')
|
||||
|
||||
const mutation = usePikkuMutation('approveEnquiry', { onSuccess: onDone })
|
||||
|
||||
const datesValid = !!startDate && !!endDate && startDate <= endDate
|
||||
const clientValid = mode === 'existing' ? !!clientId : !!email.trim()
|
||||
const canSubmit = datesValid && clientValid
|
||||
|
||||
const submit = () => {
|
||||
mutation.mutate({
|
||||
enquiryId: enquiry.enquiryId,
|
||||
startDate,
|
||||
endDate,
|
||||
client:
|
||||
mode === 'existing'
|
||||
? { clientId: clientId! }
|
||||
: {
|
||||
name: name.trim() || undefined,
|
||||
contactEmail: email.trim() || undefined,
|
||||
contactPhone: phone.trim() || undefined,
|
||||
website: website.trim() || undefined,
|
||||
billingAddress: billingAddress.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__approve_modal__title()} size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">{asI18n(`${enquiry.eventName}`)}</Text>
|
||||
|
||||
{enquiry.dateOptions.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={6}>
|
||||
{m.common__pages__admin__enquiries__approve_modal__date_options()}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{enquiry.dateOptions.map((o, i) => {
|
||||
const selected = o.startDate === startDate && o.endDate === endDate
|
||||
return (
|
||||
<Badge
|
||||
key={o.optionId}
|
||||
variant={selected ? 'filled' : 'light'}
|
||||
color={selected ? 'plum' : 'gray'}
|
||||
style={{ cursor: 'pointer', textTransform: 'none' }}
|
||||
onClick={() => {
|
||||
setStartDate(o.startDate)
|
||||
setEndDate(o.endDate)
|
||||
}}
|
||||
>
|
||||
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
data-testid="approve-start-date"
|
||||
type="date"
|
||||
label={m.common__pages__admin__enquiries__approve_modal__start_date()}
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
data-testid="approve-end-date"
|
||||
type="date"
|
||||
label={m.common__pages__admin__enquiries__approve_modal__end_date()}
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.currentTarget.value)}
|
||||
error={startDate && endDate && startDate > endDate ? m.common__pages__admin__enquiries__approve_modal__date_error() : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as 'existing' | 'new')}
|
||||
data={[
|
||||
{ value: 'existing', label: m.common__pages__admin__enquiries__approve_modal__existing_client() },
|
||||
{ value: 'new', label: m.common__pages__admin__enquiries__approve_modal__new_client() },
|
||||
]}
|
||||
/>
|
||||
|
||||
{mode === 'existing' ? (
|
||||
<Select
|
||||
data-testid="approve-client-select"
|
||||
label={m.common__pages__admin__enquiries__approve_modal__select_client()}
|
||||
placeholder={m.common__pages__admin__enquiries__approve_modal__search_client()}
|
||||
searchable
|
||||
data={clientOptions}
|
||||
value={clientId}
|
||||
onChange={setClientId}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
rightSection={clientsFetching ? <Loader size="xs" /> : undefined}
|
||||
nothingFoundMessage={m.common__pages__admin__enquiries__approve_modal__no_clients()}
|
||||
filter={({ options }) => options}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
data-testid="approve-new-name"
|
||||
label={m.common__pages__admin__enquiries__approve_modal__client_name()}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
data-testid="approve-new-email"
|
||||
type="email"
|
||||
label={m.common__pages__admin__enquiries__approve_modal__client_email()}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__admin__enquiries__approve_modal__client_phone()}
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label={m.common__pages__admin__enquiries__approve_modal__client_website()}
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__admin__enquiries__approve_modal__client_billing_address()}
|
||||
value={billingAddress}
|
||||
onChange={(e) => setBillingAddress(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{mutation.error && (
|
||||
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
|
||||
<Button
|
||||
data-testid="approve-confirm"
|
||||
color="green"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit || mutation.isPending}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__approve()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Waitlist modal ─────────────────────────────────────────────────────────
|
||||
|
||||
function WaitlistModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
||||
useLocale()
|
||||
const mutation = usePikkuMutation('waitlistEnquiry', { onSuccess: onDone })
|
||||
|
||||
const hasOptions = enquiry.dateOptions.length > 0
|
||||
|
||||
return (
|
||||
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__waitlist_modal__title()} size="md">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">{m.common__pages__admin__enquiries__waitlist_modal__intro()}</Text>
|
||||
{hasOptions ? (
|
||||
<Stack gap={4}>
|
||||
{enquiry.dateOptions.map((o, i) => (
|
||||
<Text key={o.optionId} size="sm">
|
||||
{asI18n(`${i + 1}. ${fmtDateShort(optDate(o.startDate), getLocale())} → ${fmtDateShort(optDate(o.endDate), getLocale())}`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert color="orange">{m.common__pages__admin__enquiries__waitlist_modal__no_options()}</Alert>
|
||||
)}
|
||||
{mutation.error && (
|
||||
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
|
||||
<Button
|
||||
data-testid="waitlist-confirm"
|
||||
color="orange"
|
||||
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
|
||||
disabled={!hasOptions || mutation.isPending}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__waitlist()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Decline modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function DeclineModal({ enquiry, onClose, onDone }: { enquiry: Enquiry; onClose: () => void; onDone: () => void }) {
|
||||
useLocale()
|
||||
const mutation = usePikkuMutation('declineEnquiry', { onSuccess: onDone })
|
||||
|
||||
return (
|
||||
<Modal opened onClose={onClose} title={m.common__pages__admin__enquiries__decline_modal__title()} size="md">
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{m.common__pages__admin__enquiries__decline_modal__confirm({
|
||||
event: enquiry.eventName,
|
||||
date: enquiry.createdAt ? fmtDate(enquiry.createdAt, getLocale()) : '',
|
||||
})}
|
||||
</Text>
|
||||
{mutation.error && (
|
||||
<Alert color="red">{asI18n(`${mutation.error.message || m.common__pages__admin__enquiries__transition_error()}`)}</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>{m.common__actions__cancel()}</Button>
|
||||
<Button
|
||||
data-testid="decline-confirm"
|
||||
color="red"
|
||||
onClick={() => mutation.mutate({ enquiryId: enquiry.enquiryId })}
|
||||
disabled={mutation.isPending}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{m.common__pages__admin__enquiries__actions__decline()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminEnquiriesPage() {
|
||||
return (
|
||||
<RequireAuth roles={['admin', 'owner']}>
|
||||
<AdminEnquiriesInner />
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/admin/enquiries')({
|
||||
component: AdminEnquiriesPage,
|
||||
})
|
||||
309
apps/app/src/pages/admin.index.tsx
Normal file
309
apps/app/src/pages/admin.index.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import {
|
||||
CalendarDays,
|
||||
FileClock,
|
||||
FileText,
|
||||
Inbox,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { RequireAuth } from '../auth'
|
||||
import { usePageTitle } from '../components/AppLayout'
|
||||
import { fmtDateShort } from '../lib/dates'
|
||||
|
||||
// One row inside an action card: clickable, navigates to the relevant workspace.
|
||||
function Row({
|
||||
primary,
|
||||
secondary,
|
||||
date,
|
||||
trailing,
|
||||
onClick,
|
||||
}: {
|
||||
primary: string
|
||||
secondary?: string | null
|
||||
date: string
|
||||
trailing?: React.ReactNode
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="xs"
|
||||
py={6}
|
||||
onClick={onClick}
|
||||
style={{ cursor: 'pointer', borderRadius: 8 }}
|
||||
className="dash-row"
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{asI18n(`${primary}`)}
|
||||
</Text>
|
||||
{secondary && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{asI18n(`${secondary}`)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: '0 0 auto' }}>
|
||||
{trailing}
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${date}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
count: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const empty = count === 0
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb={children ? 'xs' : 0}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={empty ? 'gray' : 'plum'}
|
||||
size="md"
|
||||
radius="md"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{asI18n(`${label}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge
|
||||
color={empty ? 'gray' : 'plum'}
|
||||
variant={empty ? 'light' : 'filled'}
|
||||
radius="sm"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!empty && <Stack gap={2}>{children}</Stack>}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardInner() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
usePageTitle(m.common__pages__admin__dashboard__title())
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getAdminDashboard', {
|
||||
venueSlug: 'drawehn',
|
||||
})
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<Container py="xl">
|
||||
<Text>{m.common__states__loading()}</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<Container py="xl">
|
||||
<Text c="red">{m.common__states__error()}</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
if (!data) return null
|
||||
|
||||
const goEnquiries = () => navigate({ to: '/admin/enquiries' })
|
||||
const goBooking = (bookingId: string) =>
|
||||
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId } })
|
||||
|
||||
const more = (bucket: { count: number; items: unknown[] }) =>
|
||||
bucket.count > bucket.items.length ? (
|
||||
<Text size="xs" c="dimmed" px="xs" pt={4}>
|
||||
{m.common__pages__admin__dashboard__more({ count: bucket.count - bucket.items.length })}
|
||||
</Text>
|
||||
) : null
|
||||
|
||||
const noAction =
|
||||
data.pendingEnquiries.count === 0 &&
|
||||
data.contractsToSend.count === 0 &&
|
||||
data.contractsAwaitingResponse.count === 0 &&
|
||||
data.depositsOutstanding.count === 0
|
||||
|
||||
// A "needs action" hub should only surface what needs action — zero-count
|
||||
// cards are noise, so we drop them and let `noAction` cover the empty state.
|
||||
const bookingCard = (
|
||||
key: string,
|
||||
icon: LucideIcon,
|
||||
label: string,
|
||||
bucket: typeof data.contractsToSend,
|
||||
) =>
|
||||
bucket.count === 0 ? null : (
|
||||
<ActionCard key={key} icon={icon} label={label} count={bucket.count}>
|
||||
{bucket.items.map((b) => (
|
||||
<Row
|
||||
key={b.bookingId}
|
||||
primary={b.eventName}
|
||||
secondary={b.clientName}
|
||||
date={fmtDateShort(b.startDate, getLocale())}
|
||||
onClick={() => goBooking(b.bookingId)}
|
||||
/>
|
||||
))}
|
||||
{more(bucket)}
|
||||
</ActionCard>
|
||||
)
|
||||
|
||||
const actionCards = [
|
||||
data.pendingEnquiries.count === 0 ? null : (
|
||||
<ActionCard
|
||||
key="enq"
|
||||
icon={Inbox}
|
||||
label={m.common__pages__admin__dashboard__cards__pending_enquiries()}
|
||||
count={data.pendingEnquiries.count}
|
||||
>
|
||||
{data.pendingEnquiries.items.map((e) => (
|
||||
<Row
|
||||
key={e.enquiryId}
|
||||
primary={e.eventName}
|
||||
secondary={e.contactName}
|
||||
date={fmtDateShort(e.startDate, getLocale())}
|
||||
trailing={
|
||||
e.waitlisted ? (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
{m.common__pages__admin__dashboard__waitlisted()}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onClick={goEnquiries}
|
||||
/>
|
||||
))}
|
||||
{more(data.pendingEnquiries)}
|
||||
</ActionCard>
|
||||
),
|
||||
bookingCard('send', FileText, m.common__pages__admin__dashboard__cards__contracts_to_send(), data.contractsToSend),
|
||||
bookingCard(
|
||||
'await',
|
||||
FileClock,
|
||||
m.common__pages__admin__dashboard__cards__contracts_awaiting(),
|
||||
data.contractsAwaitingResponse,
|
||||
),
|
||||
bookingCard('dep', Wallet, m.common__pages__admin__dashboard__cards__deposits_outstanding(), data.depositsOutstanding),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Grid gap="lg">
|
||||
{/* Needs action */}
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>{m.common__pages__admin__dashboard__needs_action()}</Title>
|
||||
{noAction ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.common__pages__admin__dashboard__all_clear()}
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{actionCards}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Upcoming events */}
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Title order={4}>{m.common__pages__admin__dashboard__upcoming()}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{m.common__pages__admin__dashboard__upcoming_window()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
|
||||
{data.upcomingEvents.count === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{m.common__pages__admin__dashboard__no_upcoming()}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
{data.upcomingEvents.items.map((b) => (
|
||||
<Group
|
||||
key={b.bookingId}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="xs"
|
||||
py={8}
|
||||
onClick={() => goBooking(b.bookingId)}
|
||||
style={{ cursor: 'pointer', borderRadius: 8 }}
|
||||
className="dash-row"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="plum"
|
||||
size="lg"
|
||||
radius="md"
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{asI18n(`${b.eventName}`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{asI18n(
|
||||
`${b.clientName}${b.expectedPersons != null ? ` · ${m.common__pages__admin__dashboard__persons({ count: b.expectedPersons })}` : ''}`,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flex: '0 0 auto' }}>
|
||||
{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/admin/')({
|
||||
component: () => (
|
||||
<RequireAuth roles={['admin', 'owner']}>
|
||||
<DashboardInner />
|
||||
</RequireAuth>
|
||||
),
|
||||
})
|
||||
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,
|
||||
})
|
||||
334
apps/app/src/pages/booking-form.$token.tsx
Normal file
334
apps/app/src/pages/booking-form.$token.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Radio,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { usePikkuMutation, usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { PageTitle } from '../components/AppLayout'
|
||||
import { fmtDate } from '../lib/dates'
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
billingAddress: string
|
||||
contactPhone: string
|
||||
website: string
|
||||
paymentMethod: 'cash' | 'transfer'
|
||||
expectedPersons: string
|
||||
arrivalTime: string
|
||||
departureTime: string
|
||||
dailyPlan: string
|
||||
onlineAd: boolean
|
||||
notes: string
|
||||
acceptTerms: boolean
|
||||
acceptPrivacy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Public landing for the passwordless booking-form magic link. Consumes the
|
||||
* token (which issues a client session cookie), then renders the binding form.
|
||||
* The token IS the auth — this route is intentionally not behind RequireAuth.
|
||||
*/
|
||||
function BookingFormLanding() {
|
||||
const { token } = Route.useParams()
|
||||
useLocale()
|
||||
const qc = useQueryClient()
|
||||
const [bookingId, setBookingId] = useState<string | null>(null)
|
||||
// Outcome is tracked in component state (not the mutation observer) so it
|
||||
// survives StrictMode's mount→unmount→mount and the consumedRef guard below.
|
||||
const [phase, setPhase] = useState<'pending' | 'ready' | 'error'>('pending')
|
||||
const consumedRef = useRef(false)
|
||||
|
||||
const consume = usePikkuMutation('consumeBookingFormLink', {
|
||||
onSuccess: async (data) => {
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
setBookingId(data.bookingId)
|
||||
setPhase('ready')
|
||||
},
|
||||
onError: () => setPhase('error'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (consumedRef.current) return
|
||||
consumedRef.current = true
|
||||
consume.mutate({ token })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Alert color="red" title={m.common__pages__booking_form__link_invalid__title()}>
|
||||
{m.common__pages__booking_form__link_invalid__body()}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (!bookingId) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="plum" />
|
||||
<Text c="dimmed">{m.common__states__loading()}</Text>
|
||||
</Group>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return <BookingFormInner bookingId={bookingId} />
|
||||
}
|
||||
|
||||
function BookingFormInner({ bookingId }: { bookingId: string }) {
|
||||
useLocale()
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getBookingFormData', { bookingId })
|
||||
|
||||
const submit = usePikkuMutation('submitBookingForm', {
|
||||
onSuccess: () => setSuccess(true),
|
||||
})
|
||||
|
||||
// Seed editable state once the prefill data arrives.
|
||||
useEffect(() => {
|
||||
if (data && !form) {
|
||||
setForm({
|
||||
name: data.client.name ?? '',
|
||||
billingAddress: data.client.billingAddress ?? '',
|
||||
contactPhone: data.client.contactPhone ?? '',
|
||||
website: data.client.website ?? '',
|
||||
paymentMethod: (data.paymentMethod as 'cash' | 'transfer') ?? 'cash',
|
||||
expectedPersons: data.expectedPersons != null ? String(data.expectedPersons) : '',
|
||||
arrivalTime: data.arrivalTime ?? '',
|
||||
departureTime: data.departureTime ?? '',
|
||||
dailyPlan: data.dailyPlan ?? '',
|
||||
onlineAd: data.onlineAd,
|
||||
notes: data.notes ?? '',
|
||||
acceptTerms: false,
|
||||
acceptPrivacy: false,
|
||||
})
|
||||
}
|
||||
}, [data, form])
|
||||
|
||||
if (isLoading || !form) {
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Alert color="red">{m.common__states__error()}</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="plum" />
|
||||
</Group>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((f) => (f ? { ...f, [key]: value } : f))
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Alert color="green" title={m.common__pages__booking_form__success__title()}>
|
||||
{m.common__pages__booking_form__success__body()}
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
form.name.trim() &&
|
||||
form.billingAddress.trim() &&
|
||||
form.acceptTerms &&
|
||||
form.acceptPrivacy
|
||||
|
||||
const submitForm = () => {
|
||||
submit.mutate({
|
||||
bookingId,
|
||||
name: form.name.trim(),
|
||||
billingAddress: form.billingAddress.trim(),
|
||||
contactPhone: form.contactPhone || undefined,
|
||||
website: form.website || undefined,
|
||||
paymentMethod: form.paymentMethod,
|
||||
expectedPersons: form.expectedPersons ? Number(form.expectedPersons) : undefined,
|
||||
arrivalTime: form.arrivalTime || undefined,
|
||||
departureTime: form.departureTime || undefined,
|
||||
dailyPlan: form.dailyPlan || undefined,
|
||||
onlineAd: form.onlineAd,
|
||||
notes: form.notes || undefined,
|
||||
acceptTerms: form.acceptTerms,
|
||||
acceptPrivacy: form.acceptPrivacy,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<PageTitle title={m.common__pages__booking_form__title()} />
|
||||
<Stack gap="lg">
|
||||
<Box>
|
||||
<Title order={2}>{m.common__pages__booking_form__heading()}</Title>
|
||||
<Text c="dimmed" mt={4}>
|
||||
{asI18n(
|
||||
`${data!.eventName}${data!.startDate ? ` · ${fmtDate(new Date(data!.startDate), getLocale())}` : ''}${data!.endDate ? ` → ${fmtDate(new Date(data!.endDate), getLocale())}` : ''}`,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="lg">
|
||||
<Section title={m.common__pages__booking_form__sections__billing()}>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__name()}
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__billing_address()}
|
||||
required
|
||||
autosize
|
||||
minRows={3}
|
||||
value={form.billingAddress}
|
||||
onChange={(e) => set('billingAddress', e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__contact_phone()}
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__website()}
|
||||
value={form.website}
|
||||
onChange={(e) => set('website', e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Radio.Group
|
||||
label={m.common__pages__booking_form__fields__payment_method()}
|
||||
value={form.paymentMethod}
|
||||
onChange={(v) => set('paymentMethod', v as 'cash' | 'transfer')}
|
||||
required
|
||||
>
|
||||
<Group mt="xs" gap="lg">
|
||||
<Radio value="cash" label={m.common__pages__booking_form__payment__cash()} />
|
||||
<Radio value="transfer" label={m.common__pages__booking_form__payment__transfer()} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</Section>
|
||||
|
||||
<Divider />
|
||||
<Section title={m.common__pages__booking_form__sections__event()}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<NumberInput
|
||||
label={m.common__pages__booking_form__fields__expected_persons()}
|
||||
value={form.expectedPersons === '' ? '' : Number(form.expectedPersons)}
|
||||
onChange={(v) => set('expectedPersons', v === '' ? '' : String(v))}
|
||||
min={1}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__arrival_time()}
|
||||
value={form.arrivalTime}
|
||||
onChange={(e) => set('arrivalTime', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={m.common__pages__booking_form__fields__departure_time()}
|
||||
value={form.departureTime}
|
||||
onChange={(e) => set('departureTime', e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__daily_plan()}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.dailyPlan}
|
||||
onChange={(e) => set('dailyPlan', e.currentTarget.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__online_ad()}
|
||||
checked={form.onlineAd}
|
||||
onChange={(e) => set('onlineAd', e.currentTarget.checked)}
|
||||
/>
|
||||
<Textarea
|
||||
label={m.common__pages__booking_form__fields__notes()}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.currentTarget.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Divider />
|
||||
<Section title={m.common__pages__booking_form__sections__terms()}>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__accept_terms()}
|
||||
checked={form.acceptTerms}
|
||||
onChange={(e) => set('acceptTerms', e.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={m.common__pages__booking_form__fields__accept_privacy()}
|
||||
checked={form.acceptPrivacy}
|
||||
onChange={(e) => set('acceptPrivacy', e.currentTarget.checked)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{submit.error && <Alert color="red">{m.common__pages__booking_form__error()}</Alert>}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="plum"
|
||||
onClick={submitForm}
|
||||
disabled={!canSubmit || submit.isPending}
|
||||
loading={submit.isPending}
|
||||
>
|
||||
{m.common__pages__booking_form__submit()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
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('/booking-form/$token')({
|
||||
component: BookingFormLanding,
|
||||
})
|
||||
485
apps/app/src/pages/booking.$bookingId.tsx
Normal file
485
apps/app/src/pages/booking.$bookingId.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import {
|
||||
bookingStatus,
|
||||
dietary as dietaryLabels,
|
||||
participantKind as kindLabels,
|
||||
allergen as allergenLabels,
|
||||
} from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePikkuQuery, usePikkuMutation } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { RequireAuth } from '../auth'
|
||||
import { BOOKING_TRANSITIONS, type BookingStatus } from '../lib/status'
|
||||
import { fmtDate, toDateInputStr } from '../lib/dates'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
enquiry: 'pink',
|
||||
reserved: 'orange',
|
||||
confirmed: 'green',
|
||||
ended: 'gray',
|
||||
cancelled: 'red',
|
||||
}
|
||||
|
||||
const DIETARY_KEYS = ['omnivore', 'vegetarian', 'vegan', 'gluten_free', 'lactose_free', 'pescatarian'] as const
|
||||
const DIETARY_COLORS: Record<string, string> = {
|
||||
omnivore: '#94a3b8',
|
||||
vegetarian: '#84cc16',
|
||||
vegan: '#22c55e',
|
||||
gluten_free: '#eab308',
|
||||
lactose_free: '#06b6d4',
|
||||
pescatarian: '#0ea5e9',
|
||||
unknown: '#64748b',
|
||||
}
|
||||
|
||||
function BookingDetailInner() {
|
||||
useLocale()
|
||||
const { bookingId } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const queryArgs = { bookingId }
|
||||
const { data, isLoading, error } = usePikkuQuery('getBookingDetail', queryArgs)
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: ['getBookingDetail', queryArgs] })
|
||||
|
||||
const addMut = usePikkuMutation('addParticipant', { onSuccess: invalidate })
|
||||
const updateMut = usePikkuMutation('updateParticipant', { onSuccess: invalidate })
|
||||
const removeMut = usePikkuMutation('removeParticipant', { onSuccess: invalidate })
|
||||
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newKind, setNewKind] = useState<'overnight' | 'day_guest'>('overnight')
|
||||
|
||||
if (isLoading) return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
if (error) return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
|
||||
if (!data) return null
|
||||
|
||||
const { booking, client, participants, invoices, dietaryBreakdown } = data
|
||||
const totalParticipants = participants.length
|
||||
const dietaryTotal = dietaryBreakdown.reduce((s, d) => s + d.count, 0) || 1
|
||||
|
||||
const dietaryOptions = DIETARY_KEYS.map((k) => ({
|
||||
value: k,
|
||||
label: dietaryLabels[k](),
|
||||
}))
|
||||
|
||||
const submitNew = () => {
|
||||
if (!newName.trim()) return
|
||||
addMut.mutate(
|
||||
{ bookingId, name: newName.trim(), kind: newKind, email: null, age: null, dietaryTag: null, notes: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
setNewKind('overnight')
|
||||
setAdding(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Button component={Link} to="/admin/bookings" size="xs" variant="subtle">
|
||||
{asI18n(`← ${m.common__pages__admin__bookings__title()}`)}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{asI18n(`${booking.eventName}`)}</Title>
|
||||
<Text size="sm" c="dimmed">{asI18n(`${client.name}`)}</Text>
|
||||
<Text size="sm">{asI18n(`${fmtDate(booking.startDate, getLocale())} → ${fmtDate(booking.endDate, getLocale())}`)}</Text>
|
||||
</Stack>
|
||||
<Group gap="xs" align="center">
|
||||
<Badge color={STATUS_COLORS[booking.status] ?? 'gray'} size="lg">
|
||||
{bookingStatus[booking.status as BookingStatus]()}
|
||||
</Badge>
|
||||
<StatusChanger
|
||||
bookingId={booking.bookingId}
|
||||
status={booking.status}
|
||||
onChanged={invalidate}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Tabs defaultValue="participants">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">{m.common__pages__booking__tabs__overview()}</Tabs.Tab>
|
||||
<Tabs.Tab value="participants">
|
||||
{asI18n(`${m.common__pages__booking__tabs__participants()} (${totalParticipants})`)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices">
|
||||
{asI18n(`${m.common__pages__booking__tabs__invoices()} (${invoices.length})`)}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="md">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>{m.common__pages__booking__overview__schedule()}</Title>
|
||||
<Group gap="lg">
|
||||
<Text size="sm">
|
||||
<b>{asI18n(`${m.common__pages__booking__overview__breakfast()}:`)}</b>{asI18n(` ${booking.mealTimeBreakfast ?? '—'}`)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<b>{asI18n(`${m.common__pages__booking__overview__lunch()}:`)}</b>{asI18n(` ${booking.mealTimeLunch ?? '—'}`)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<b>{asI18n(`${m.common__pages__booking__overview__dinner()}:`)}</b>{asI18n(` ${booking.mealTimeDinner ?? '—'}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
{booking.eventOutline && (
|
||||
<Text size="sm" fw={500}>{asI18n(`${booking.eventOutline}`)}</Text>
|
||||
)}
|
||||
{booking.description && (
|
||||
<Text size="sm" c="dimmed">{asI18n(`${booking.description}`)}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>{m.common__pages__booking__overview__headcount()}</Title>
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm">
|
||||
{m.common__pages__booking__overview__added({
|
||||
count: totalParticipants,
|
||||
target: booking.expectedPersons ?? '?',
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={(totalParticipants / Math.max(booking.expectedPersons ?? 1, 1)) * 100} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="participants" pt="md">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group align="flex-start" justify="space-between">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__booking__participants__dietary_breakdown()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__booking__participants__total_participants({ count: totalParticipants })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<RingProgress
|
||||
size={120}
|
||||
thickness={14}
|
||||
sections={dietaryBreakdown.map((d) => ({
|
||||
value: (d.count / dietaryTotal) * 100,
|
||||
color: DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown,
|
||||
tooltip: `${d.tag in dietaryLabels ? dietaryLabels[d.tag as keyof typeof dietaryLabels]() : asI18n(d.tag)}: ${d.count}`,
|
||||
}))}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" mt="md">
|
||||
{dietaryBreakdown.map((d) => (
|
||||
<Badge
|
||||
key={d.tag}
|
||||
color="gray"
|
||||
variant="light"
|
||||
style={{ borderLeft: `3px solid ${DIETARY_COLORS[d.tag] ?? DIETARY_COLORS.unknown}` }}
|
||||
>
|
||||
{asI18n(`${d.tag in dietaryLabels ? dietaryLabels[d.tag as keyof typeof dietaryLabels]() : asI18n(d.tag)}: ${d.count}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>{m.common__pages__booking__participants__title()}</Title>
|
||||
<Button size="xs" onClick={() => setAdding(true)}>
|
||||
{m.common__pages__booking__participants__add_cta()}
|
||||
</Button>
|
||||
</Group>
|
||||
{participants.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">{m.common__pages__booking__participants__empty()}</Text>
|
||||
) : (
|
||||
<Table verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.common__pages__booking__participants__cols__name()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__participants__cols__kind()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__participants__cols__diet()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__participants__cols__allergies()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__participants__cols__room()}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{participants.map((p) => (
|
||||
<Table.Tr key={p.participantId}>
|
||||
<Table.Td>{p.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={p.kind === 'day_guest' ? 'cyan' : 'blue'}>
|
||||
{kindLabels[p.kind as keyof typeof kindLabels]()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
size="xs"
|
||||
placeholder={asI18n('—')}
|
||||
data={dietaryOptions}
|
||||
value={p.dietaryTag ?? null}
|
||||
onChange={(value) =>
|
||||
updateMut.mutate({
|
||||
participantId: p.participantId,
|
||||
dietaryTag: value as any,
|
||||
})
|
||||
}
|
||||
clearable
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{p.allergies.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">{asI18n('—')}</Text>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
{p.allergies.map((a) => (
|
||||
<Badge
|
||||
key={a.allergen}
|
||||
size="xs"
|
||||
color={a.severity === 'separate_prep' ? 'red' : 'orange'}
|
||||
variant="light"
|
||||
>
|
||||
{a.allergen in allergenLabels
|
||||
? allergenLabels[a.allergen as keyof typeof allergenLabels]()
|
||||
: asI18n(a.allergen)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{p.roomNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
m.common__pages__booking__participants__remove_confirm({ name: p.name }),
|
||||
)
|
||||
) {
|
||||
removeMut.mutate({
|
||||
participantId: p.participantId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="invoices" pt="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
{invoices.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">{m.common__pages__booking__invoices__empty()}</Text>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__number()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__kind()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__amount()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__status()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__issued()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__due()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__booking__invoices__cols__paid()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{invoices.map((inv) => (
|
||||
<Table.Tr key={inv.invoiceId}>
|
||||
<Table.Td>
|
||||
{inv.pdfUrl ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={inv.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={`${inv.invoiceNumber}.pdf`}
|
||||
variant="subtle"
|
||||
px={0}
|
||||
>
|
||||
{asI18n(`${inv.invoiceNumber}`)}
|
||||
</Button>
|
||||
) : (
|
||||
inv.invoiceNumber
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{inv.kind}</Table.Td>
|
||||
<Table.Td>€{(inv.amountCents / 100).toFixed(2)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={inv.status === 'paid' ? 'green' : 'orange'}>{asI18n(`${inv.status}`)}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{fmtDate(inv.issuedOn, getLocale())}</Table.Td>
|
||||
<Table.Td>{inv.dueOn ? fmtDate(inv.dueOn, getLocale()) : '—'}</Table.Td>
|
||||
<Table.Td>{inv.paidOn ? fmtDate(inv.paidOn, getLocale()) : '—'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
title={m.common__pages__booking__participants__add_modal__title()}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={m.common__pages__booking__participants__add_modal__name()}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={m.common__pages__booking__participants__add_modal__kind()}
|
||||
data={[
|
||||
{ value: 'overnight', label: kindLabels.overnight() },
|
||||
{ value: 'day_guest', label: kindLabels.day_guest() },
|
||||
]}
|
||||
value={newKind}
|
||||
onChange={(v) => setNewKind((v as any) ?? 'overnight')}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={() => setAdding(false)}>
|
||||
{m.common__actions__cancel()}
|
||||
</Button>
|
||||
<Button onClick={submitNew} loading={addMut.isPending}>
|
||||
{m.common__pages__booking__participants__add_modal__add()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusChanger({
|
||||
bookingId,
|
||||
status,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string
|
||||
status: string
|
||||
onChanged: () => void
|
||||
}) {
|
||||
useLocale()
|
||||
const [pending, setPending] = useState<string | null>(null)
|
||||
const mutation = usePikkuMutation('setBookingStatus', {
|
||||
onSuccess: () => {
|
||||
setPending(null)
|
||||
onChanged()
|
||||
},
|
||||
})
|
||||
const next = BOOKING_TRANSITIONS[status as BookingStatus] ?? []
|
||||
if (next.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
size="xs"
|
||||
placeholder={m.common__pages__booking_status__advance()}
|
||||
data={next.map((s) => ({
|
||||
value: s,
|
||||
label: bookingStatus[s](),
|
||||
}))}
|
||||
value={null}
|
||||
onChange={(v) => v && setPending(v)}
|
||||
clearable={false}
|
||||
/>
|
||||
<Modal
|
||||
opened={pending !== null}
|
||||
onClose={() => setPending(null)}
|
||||
title={m.common__pages__booking_status__confirm_title()}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{pending && (
|
||||
<Text size="sm">
|
||||
{m.common__pages__booking_status__confirm_body({
|
||||
status: bookingStatus[pending as BookingStatus](),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={() => setPending(null)}>
|
||||
{m.common__actions__cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
onClick={() =>
|
||||
pending &&
|
||||
mutation.mutate({
|
||||
bookingId,
|
||||
status: pending as BookingStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
{m.common__actions__confirm()}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingDetailPage() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<BookingDetailInner />
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/booking/$bookingId')({
|
||||
component: BookingDetailPage,
|
||||
})
|
||||
382
apps/app/src/pages/enquiry.tsx
Normal file
382
apps/app/src/pages/enquiry.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { type I18nMessage } from '@/i18n/i18n-enum.gen'
|
||||
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={m.common__pages__enquiry__hints__event_name} />}
|
||||
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={m.common__pages__enquiry__hints__event_outline} />}
|
||||
value={form.eventOutline}
|
||||
onChange={(e) => set('eventOutline', e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
data-testid="description"
|
||||
label={<FieldLabel label={m.common__pages__enquiry__fields__description()} hint={m.common__pages__enquiry__hints__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={m.common__pages__enquiry__hints__date_options} />
|
||||
{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={m.common__pages__enquiry__hints__expected_persons} />}
|
||||
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={m.common__pages__enquiry__hints__half_house} />}
|
||||
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={m.common__pages__enquiry__hints__client_name} />}
|
||||
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={m.common__pages__enquiry__hints__contact_email} />}
|
||||
required
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => set('contactEmail', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={<FieldLabel label={m.common__pages__enquiry__fields__contact_phone()} hint={m.common__pages__enquiry__hints__contact_phone} />}
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={<FieldLabel label={m.common__pages__enquiry__fields__website()} hint={m.common__pages__enquiry__hints__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={m.common__pages__enquiry__hints__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={m.common__pages__enquiry__hints__privacy_accepted} />}
|
||||
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?: I18nMessage }) {
|
||||
useLocale()
|
||||
if (!hint) return <>{label}</>
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
{label}
|
||||
<Tooltip
|
||||
label={hint()}
|
||||
multiline
|
||||
w={240}
|
||||
withArrow
|
||||
events={{ hover: true, focus: true, touch: true }}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
aria-label={hint()}
|
||||
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,
|
||||
}),
|
||||
})
|
||||
113
apps/app/src/pages/events.tsx
Normal file
113
apps/app/src/pages/events.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@pikku/mantine/core'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { MarketingShell } from '../components/MarketingShell'
|
||||
import { EventCard } from '../components/EventCard'
|
||||
import { PageTitle } from '../components/AppLayout'
|
||||
|
||||
type Filter = 'this_year' | 'next_year' | 'all'
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={active ? 'filled' : 'light'}
|
||||
color={active ? 'plum' : 'gray'}
|
||||
style={{ cursor: 'pointer', textTransform: 'none' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{asI18n(`${children}`)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function EventsPage() {
|
||||
useLocale()
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getEventsListing', {
|
||||
venueSlug: 'drawehn',
|
||||
filter,
|
||||
search: search.trim() || undefined,
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<MarketingShell>
|
||||
<Container size="lg" py="xl">
|
||||
<Stack gap="lg">
|
||||
<PageTitle title={m.common__pages__events__title()} />
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder={m.common__pages__events__search_placeholder()}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<FilterChip active={filter === 'this_year'} onClick={() => setFilter('this_year')}>
|
||||
{m.common__pages__events__filters__this_year()}
|
||||
</FilterChip>
|
||||
<FilterChip active={filter === 'next_year'} onClick={() => setFilter('next_year')}>
|
||||
{m.common__pages__events__filters__next_year()}
|
||||
</FilterChip>
|
||||
<FilterChip active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
{m.common__pages__events__filters__all()}
|
||||
</FilterChip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{data.events.length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">{m.common__pages__events__empty()}</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{data.events.map((e) => (
|
||||
<EventCard
|
||||
key={e.bookingId}
|
||||
eventName={e.eventName}
|
||||
startDate={e.startDate}
|
||||
endDate={e.endDate}
|
||||
clientName={e.clientName}
|
||||
coverImageUrl={e.coverImageUrl}
|
||||
eventOutline={e.eventOutline}
|
||||
description={e.description}
|
||||
organiserWebsite={e.organiserWebsite}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/events')({
|
||||
component: EventsPage,
|
||||
})
|
||||
276
apps/app/src/pages/index.tsx
Normal file
276
apps/app/src/pages/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Navigate, createFileRoute } from '@tanstack/react-router'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { BrandHeader } from '../components/Brand'
|
||||
import { RequireAuth, useAuth } from '../auth'
|
||||
import { usePageTitle } from '../components/AppLayout'
|
||||
import { fmtDate } from '../lib/dates'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
|
||||
function MarketingPage({ hero, sections }: { hero: React.ReactNode; sections: React.ReactNode[] }) {
|
||||
return (
|
||||
<Box>
|
||||
{hero}
|
||||
<Container size="md" py="xl">
|
||||
<Stack gap="lg">{sections}</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexPage() {
|
||||
useLocale()
|
||||
|
||||
return (
|
||||
<MarketingPage
|
||||
hero={(
|
||||
<Box bg="var(--brand-plum)" py="xl" c="white">
|
||||
<Container size="md">
|
||||
<BrandHeader variant="white" />
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
sections={[
|
||||
<Paper key="launchpad" withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
{m.common__pages__launchpad__title()}
|
||||
</Text>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__launchpad__client__title()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__launchpad__client__body()}
|
||||
</Text>
|
||||
<Box>
|
||||
<Button component="a" href="/admin/bookings" size="sm" variant="light">
|
||||
{m.common__pages__launchpad__client__cta()}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__launchpad__availability__title()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__launchpad__availability__body()}
|
||||
</Text>
|
||||
<Box>
|
||||
<Button component="a" href="/availability" size="sm" variant="light">
|
||||
{m.common__pages__launchpad__availability__cta()}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__launchpad__admin__title()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__launchpad__admin__body()}
|
||||
</Text>
|
||||
<Box>
|
||||
<Button component="a" href="/admin/bookings" size="sm" variant="light">
|
||||
{m.common__pages__launchpad__admin__cta()}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__launchpad__events__title()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__launchpad__events__body()}
|
||||
</Text>
|
||||
<Box>
|
||||
<Button component="a" href="/events" size="sm" variant="light">
|
||||
{m.common__pages__launchpad__events__cta()}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{m.common__pages__launchpad__enquiry__title()}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__launchpad__enquiry__body()}
|
||||
</Text>
|
||||
<Box>
|
||||
<Button component="a" href="/enquiry" size="sm" variant="light">
|
||||
{m.common__pages__launchpad__enquiry__cta()}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Paper>,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientOverview() {
|
||||
useLocale()
|
||||
usePageTitle(m.common__pages__client__title())
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getClientOverview', null)
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
}
|
||||
if (error && !data) {
|
||||
return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
|
||||
}
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
{m.common__pages__client__title()}
|
||||
</Text>
|
||||
<Title order={2}>{m.common__brand__name()}</Title>
|
||||
{data.clients.length > 0 && (
|
||||
<Text c="dimmed">
|
||||
{asI18n(data.clients.map((client) => client.name).join(', '))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Title order={3}>{m.common__pages__client__upcoming()}</Title>
|
||||
{data.upcomingBookings.length === 0 ? (
|
||||
<Text c="dimmed">{m.common__pages__client__no_upcoming()}</Text>
|
||||
) : (
|
||||
data.upcomingBookings.map((booking) => (
|
||||
<Paper key={booking.bookingId} withBorder radius="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Stack gap={4}>
|
||||
<Text fw={600}>{asI18n(`${booking.eventName}`)}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{asI18n(`${fmtDate(booking.startDate, getLocale())} - ${fmtDate(booking.endDate, getLocale())}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
{booking.isCurrentEvent && (
|
||||
<Box>
|
||||
<Badge color="plum" variant="light">
|
||||
{m.common__pages__client__current_badge()}
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
<Text size="sm">
|
||||
{booking.expectedPersons
|
||||
? m.common__pages__client__participants_count({
|
||||
count: booking.participantCount,
|
||||
target: booking.expectedPersons,
|
||||
})
|
||||
: m.common__pages__client__participants_count_unknown({
|
||||
count: booking.participantCount,
|
||||
})}
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{m.common__pages__client__completeness()}
|
||||
</Text>
|
||||
<Progress
|
||||
color="plum"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
value={booking.completenessPercent}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Title order={3}>{m.common__pages__client__recent_invoices()}</Title>
|
||||
{data.recentInvoices.length === 0 ? (
|
||||
<Text c="dimmed">{m.common__pages__client__no_invoices()}</Text>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__booking()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__number()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__kind()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__amount()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__status()}</Table.Th>
|
||||
<Table.Th>{m.common__pages__client__invoice_cols__issued()}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.recentInvoices.map((invoice) => (
|
||||
<Table.Tr key={invoice.invoiceId}>
|
||||
<Table.Td>{invoice.bookingName}</Table.Td>
|
||||
<Table.Td>
|
||||
{invoice.pdfUrl ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={invoice.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={`${invoice.invoiceNumber}.pdf`}
|
||||
variant="subtle"
|
||||
px={0}
|
||||
>
|
||||
{asI18n(`${invoice.invoiceNumber}`)}
|
||||
</Button>
|
||||
) : (
|
||||
invoice.invoiceNumber
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{invoice.kind}</Table.Td>
|
||||
<Table.Td>{(invoice.amountCents / 100).toFixed(2)} €</Table.Td>
|
||||
<Table.Td>{invoice.status}</Table.Td>
|
||||
<Table.Td>{fmtDate(invoice.issuedOn, getLocale())}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { role } = useAuth()
|
||||
if (role === 'admin' || role === 'owner') return <Navigate to="/admin/bookings" />
|
||||
if (role === 'client') return <ClientOverview />
|
||||
return <IndexPage />
|
||||
}
|
||||
|
||||
function IndexRoute() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<Dashboard />
|
||||
</RequireAuth>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: IndexRoute,
|
||||
})
|
||||
157
apps/app/src/pages/kanban.tsx
Normal file
157
apps/app/src/pages/kanban.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { bookingStatus } from '@/i18n/i18n-enum.gen'
|
||||
import { useLocale, getLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { Box, Container, Group, Paper, Stack, Text } from '@pikku/mantine/core'
|
||||
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
|
||||
import { usePageTitle } from '../components/AppLayout'
|
||||
import { PublicShell } from '../components/PublicShell'
|
||||
import { BOOKING_STATUS, type BookingStatus } from '../lib/status'
|
||||
import { fmtDateShort } from '../lib/dates'
|
||||
|
||||
const COLUMNS: BookingStatus[] = ['enquiry', 'reserved', 'confirmed', 'ended', 'cancelled']
|
||||
|
||||
type Booking = {
|
||||
bookingId: string
|
||||
eventName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
status: string
|
||||
clientName: string
|
||||
isStammgruppe: boolean
|
||||
depositPaid: boolean
|
||||
hasDeposit: boolean
|
||||
halfHouse: boolean
|
||||
}
|
||||
|
||||
function BookingCard({ booking, locale }: { booking: Booking; locale: string }) {
|
||||
useLocale()
|
||||
const palette = BOOKING_STATUS[booking.status as BookingStatus]
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/admin/bookings/$bookingId"
|
||||
params={{ bookingId: booking.bookingId }}
|
||||
style={{ textDecoration: 'none', display: 'block' }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderLeft: `3px solid ${palette.dot}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600} lineClamp={2} c="var(--mantine-color-text)">
|
||||
{asI18n(`${booking.eventName}`)}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{booking.isStammgruppe && (
|
||||
<Text size="xs" c="yellow.6" title={m.common__pages__admin_booking__client__regular_group()} style={{ cursor: 'default' }}>{asI18n('★')}</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{asI18n(`${booking.clientName}`)}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{asI18n(`${fmtDateShort(booking.startDate, locale)} – ${fmtDateShort(booking.endDate, locale)}`)}
|
||||
</Text>
|
||||
{booking.halfHouse && (
|
||||
<Text size="xs" c="dimmed">{asI18n('½')}</Text>
|
||||
)}
|
||||
{booking.hasDeposit && (
|
||||
<Text size="xs" c={booking.depositPaid ? 'green' : 'orange'} fw={500}>
|
||||
{(booking.depositPaid ? m.common__pages__admin__bookings__deposit__paid : m.common__pages__admin__bookings__deposit__outstanding)()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanInner() {
|
||||
useLocale()
|
||||
usePageTitle(m.common__pages__kanban__title())
|
||||
|
||||
const { data, isLoading, error } = usePikkuQuery('getAdminBookings', { venueSlug: 'drawehn' })
|
||||
|
||||
if (isLoading) return <Container py="xl"><Text>{m.common__states__loading()}</Text></Container>
|
||||
if (error) return <Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container>
|
||||
if (!data) return null
|
||||
|
||||
const byStatus = new Map<BookingStatus, Booking[]>()
|
||||
for (const col of COLUMNS) byStatus.set(col, [])
|
||||
for (const b of data.bookings) {
|
||||
const col = b.status as BookingStatus
|
||||
if (byStatus.has(col)) byStatus.get(col)!.push({ ...b, halfHouse: !!b.halfHouse } as unknown as Booking)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
minHeight: 0,
|
||||
paddingBottom: 24,
|
||||
}}
|
||||
px="xl"
|
||||
pt="md"
|
||||
>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md" style={{ minWidth: 'max-content' }}>
|
||||
{COLUMNS.map((col) => {
|
||||
const palette = BOOKING_STATUS[col]
|
||||
const cards = byStatus.get(col) ?? []
|
||||
return (
|
||||
<Stack
|
||||
key={col}
|
||||
gap="sm"
|
||||
style={{ width: 260, flexShrink: 0 }}
|
||||
>
|
||||
<Group gap="xs" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: palette.dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: '0.06em', color: palette.fg }}>
|
||||
{bookingStatus[col]()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
{cards.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{cards.length === 0 ? (
|
||||
<Paper withBorder radius="md" p="sm" style={{ borderStyle: 'dashed' }}>
|
||||
<Text size="xs" c="dimmed" ta="center">{asI18n('—')}</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
cards.map((b) => (
|
||||
<BookingCard key={b.bookingId} booking={b} locale={getLocale()} />
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function KanbanPage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<KanbanInner />
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/kanban')({
|
||||
component: KanbanPage,
|
||||
})
|
||||
92
apps/app/src/pages/login.tsx
Normal file
92
apps/app/src/pages/login.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { startTransition, useState } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@pikku/mantine/core'
|
||||
import { BrandMark } from '../components/Brand'
|
||||
import { signIn } from '../lib/auth'
|
||||
|
||||
function LoginPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [email, setEmail] = useState(import.meta.env.DEV ? 'sarah@seminarhof.example' : '')
|
||||
const [password, setPassword] = useState(import.meta.env.DEV ? 'owner1234' : '')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsPending(true)
|
||||
setError(false)
|
||||
try {
|
||||
await signIn(email, password)
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
startTransition(() => {
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
size="xs"
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper withBorder radius="md" p="lg" w="100%">
|
||||
<Stack gap="md" align="stretch">
|
||||
<Stack gap={4} align="center">
|
||||
<BrandMark size={64} />
|
||||
<Title order={2} ta="center" c="var(--brand-plum)">{m.auth__login__title()}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">{m.auth__login__subtitle()}</Text>
|
||||
</Stack>
|
||||
<TextInput
|
||||
type="email"
|
||||
label={m.auth__login__email()}
|
||||
placeholder={m.auth__login__email_placeholder()}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={m.auth__login__password()}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
{error && (
|
||||
<Alert color="red">{m.auth__login__errors__invalid_credentials()}</Alert>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!email || !password || isPending}
|
||||
loading={isPending}
|
||||
>
|
||||
{m.auth__login__submit()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
36
apps/app/src/pages/logout.tsx
Normal file
36
apps/app/src/pages/logout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { m } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Container, Paper, Stack, Text, Title, Button } from '@pikku/mantine/core'
|
||||
import { signOut } from '../lib/auth'
|
||||
|
||||
function LogoutPage() {
|
||||
useLocale()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
signOut().then(() => qc.clear()).catch(() => qc.clear())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container size="xs" py="xl">
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Title order={2}>{m.auth__logout__title()}</Title>
|
||||
<Text size="sm" c="dimmed">{m.auth__logout__subtitle()}</Text>
|
||||
<Button onClick={() => navigate({ to: '/login' })}>
|
||||
{m.auth__logout__back_to_login()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/logout')({
|
||||
component: LogoutPage,
|
||||
})
|
||||
353
apps/app/src/routeTree.gen.ts
Normal file
353
apps/app/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './pages/__root'
|
||||
import { Route as LogoutRouteImport } from './pages/logout'
|
||||
import { Route as LoginRouteImport } from './pages/login'
|
||||
import { Route as KanbanRouteImport } from './pages/kanban'
|
||||
import { Route as EventsRouteImport } from './pages/events'
|
||||
import { Route as EnquiryRouteImport } from './pages/enquiry'
|
||||
import { Route as AvailabilityRouteImport } from './pages/availability'
|
||||
import { Route as IndexRouteImport } from './pages/index'
|
||||
import { Route as AdminIndexRouteImport } from './pages/admin.index'
|
||||
import { Route as BookingBookingIdRouteImport } from './pages/booking.$bookingId'
|
||||
import { Route as BookingFormTokenRouteImport } from './pages/booking-form.$token'
|
||||
import { Route as AdminEnquiriesRouteImport } from './pages/admin.enquiries'
|
||||
import { Route as AdminBookingsRouteImport } from './pages/admin.bookings'
|
||||
import { Route as AdminBookingsIndexRouteImport } from './pages/admin.bookings.index'
|
||||
import { Route as AdminBookingsBookingIdRouteImport } from './pages/admin.bookings.$bookingId'
|
||||
|
||||
const LogoutRoute = LogoutRouteImport.update({
|
||||
id: '/logout',
|
||||
path: '/logout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const KanbanRoute = KanbanRouteImport.update({
|
||||
id: '/kanban',
|
||||
path: '/kanban',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EventsRoute = EventsRouteImport.update({
|
||||
id: '/events',
|
||||
path: '/events',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EnquiryRoute = EnquiryRouteImport.update({
|
||||
id: '/enquiry',
|
||||
path: '/enquiry',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AvailabilityRoute = AvailabilityRouteImport.update({
|
||||
id: '/availability',
|
||||
path: '/availability',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||
id: '/admin/',
|
||||
path: '/admin/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BookingBookingIdRoute = BookingBookingIdRouteImport.update({
|
||||
id: '/booking/$bookingId',
|
||||
path: '/booking/$bookingId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BookingFormTokenRoute = BookingFormTokenRouteImport.update({
|
||||
id: '/booking-form/$token',
|
||||
path: '/booking-form/$token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminEnquiriesRoute = AdminEnquiriesRouteImport.update({
|
||||
id: '/admin/enquiries',
|
||||
path: '/admin/enquiries',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminBookingsRoute = AdminBookingsRouteImport.update({
|
||||
id: '/admin/bookings',
|
||||
path: '/admin/bookings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminBookingsIndexRoute = AdminBookingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AdminBookingsRoute,
|
||||
} as any)
|
||||
const AdminBookingsBookingIdRoute = AdminBookingsBookingIdRouteImport.update({
|
||||
id: '/$bookingId',
|
||||
path: '/$bookingId',
|
||||
getParentRoute: () => AdminBookingsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/availability': typeof AvailabilityRoute
|
||||
'/enquiry': typeof EnquiryRoute
|
||||
'/events': typeof EventsRoute
|
||||
'/kanban': typeof KanbanRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/admin/bookings': typeof AdminBookingsRouteWithChildren
|
||||
'/admin/enquiries': typeof AdminEnquiriesRoute
|
||||
'/booking-form/$token': typeof BookingFormTokenRoute
|
||||
'/booking/$bookingId': typeof BookingBookingIdRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
|
||||
'/admin/bookings/': typeof AdminBookingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/availability': typeof AvailabilityRoute
|
||||
'/enquiry': typeof EnquiryRoute
|
||||
'/events': typeof EventsRoute
|
||||
'/kanban': typeof KanbanRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/admin/enquiries': typeof AdminEnquiriesRoute
|
||||
'/booking-form/$token': typeof BookingFormTokenRoute
|
||||
'/booking/$bookingId': typeof BookingBookingIdRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
|
||||
'/admin/bookings': typeof AdminBookingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/availability': typeof AvailabilityRoute
|
||||
'/enquiry': typeof EnquiryRoute
|
||||
'/events': typeof EventsRoute
|
||||
'/kanban': typeof KanbanRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/admin/bookings': typeof AdminBookingsRouteWithChildren
|
||||
'/admin/enquiries': typeof AdminEnquiriesRoute
|
||||
'/booking-form/$token': typeof BookingFormTokenRoute
|
||||
'/booking/$bookingId': typeof BookingBookingIdRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/bookings/$bookingId': typeof AdminBookingsBookingIdRoute
|
||||
'/admin/bookings/': typeof AdminBookingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/availability'
|
||||
| '/enquiry'
|
||||
| '/events'
|
||||
| '/kanban'
|
||||
| '/login'
|
||||
| '/logout'
|
||||
| '/admin/bookings'
|
||||
| '/admin/enquiries'
|
||||
| '/booking-form/$token'
|
||||
| '/booking/$bookingId'
|
||||
| '/admin/'
|
||||
| '/admin/bookings/$bookingId'
|
||||
| '/admin/bookings/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/availability'
|
||||
| '/enquiry'
|
||||
| '/events'
|
||||
| '/kanban'
|
||||
| '/login'
|
||||
| '/logout'
|
||||
| '/admin/enquiries'
|
||||
| '/booking-form/$token'
|
||||
| '/booking/$bookingId'
|
||||
| '/admin'
|
||||
| '/admin/bookings/$bookingId'
|
||||
| '/admin/bookings'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/availability'
|
||||
| '/enquiry'
|
||||
| '/events'
|
||||
| '/kanban'
|
||||
| '/login'
|
||||
| '/logout'
|
||||
| '/admin/bookings'
|
||||
| '/admin/enquiries'
|
||||
| '/booking-form/$token'
|
||||
| '/booking/$bookingId'
|
||||
| '/admin/'
|
||||
| '/admin/bookings/$bookingId'
|
||||
| '/admin/bookings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AvailabilityRoute: typeof AvailabilityRoute
|
||||
EnquiryRoute: typeof EnquiryRoute
|
||||
EventsRoute: typeof EventsRoute
|
||||
KanbanRoute: typeof KanbanRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
LogoutRoute: typeof LogoutRoute
|
||||
AdminBookingsRoute: typeof AdminBookingsRouteWithChildren
|
||||
AdminEnquiriesRoute: typeof AdminEnquiriesRoute
|
||||
BookingFormTokenRoute: typeof BookingFormTokenRoute
|
||||
BookingBookingIdRoute: typeof BookingBookingIdRoute
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/logout': {
|
||||
id: '/logout'
|
||||
path: '/logout'
|
||||
fullPath: '/logout'
|
||||
preLoaderRoute: typeof LogoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/kanban': {
|
||||
id: '/kanban'
|
||||
path: '/kanban'
|
||||
fullPath: '/kanban'
|
||||
preLoaderRoute: typeof KanbanRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/events': {
|
||||
id: '/events'
|
||||
path: '/events'
|
||||
fullPath: '/events'
|
||||
preLoaderRoute: typeof EventsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/enquiry': {
|
||||
id: '/enquiry'
|
||||
path: '/enquiry'
|
||||
fullPath: '/enquiry'
|
||||
preLoaderRoute: typeof EnquiryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/availability': {
|
||||
id: '/availability'
|
||||
path: '/availability'
|
||||
fullPath: '/availability'
|
||||
preLoaderRoute: typeof AvailabilityRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/': {
|
||||
id: '/admin/'
|
||||
path: '/admin'
|
||||
fullPath: '/admin/'
|
||||
preLoaderRoute: typeof AdminIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/booking/$bookingId': {
|
||||
id: '/booking/$bookingId'
|
||||
path: '/booking/$bookingId'
|
||||
fullPath: '/booking/$bookingId'
|
||||
preLoaderRoute: typeof BookingBookingIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/booking-form/$token': {
|
||||
id: '/booking-form/$token'
|
||||
path: '/booking-form/$token'
|
||||
fullPath: '/booking-form/$token'
|
||||
preLoaderRoute: typeof BookingFormTokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/enquiries': {
|
||||
id: '/admin/enquiries'
|
||||
path: '/admin/enquiries'
|
||||
fullPath: '/admin/enquiries'
|
||||
preLoaderRoute: typeof AdminEnquiriesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/bookings': {
|
||||
id: '/admin/bookings'
|
||||
path: '/admin/bookings'
|
||||
fullPath: '/admin/bookings'
|
||||
preLoaderRoute: typeof AdminBookingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/bookings/': {
|
||||
id: '/admin/bookings/'
|
||||
path: '/'
|
||||
fullPath: '/admin/bookings/'
|
||||
preLoaderRoute: typeof AdminBookingsIndexRouteImport
|
||||
parentRoute: typeof AdminBookingsRoute
|
||||
}
|
||||
'/admin/bookings/$bookingId': {
|
||||
id: '/admin/bookings/$bookingId'
|
||||
path: '/$bookingId'
|
||||
fullPath: '/admin/bookings/$bookingId'
|
||||
preLoaderRoute: typeof AdminBookingsBookingIdRouteImport
|
||||
parentRoute: typeof AdminBookingsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminBookingsRouteChildren {
|
||||
AdminBookingsBookingIdRoute: typeof AdminBookingsBookingIdRoute
|
||||
AdminBookingsIndexRoute: typeof AdminBookingsIndexRoute
|
||||
}
|
||||
|
||||
const AdminBookingsRouteChildren: AdminBookingsRouteChildren = {
|
||||
AdminBookingsBookingIdRoute: AdminBookingsBookingIdRoute,
|
||||
AdminBookingsIndexRoute: AdminBookingsIndexRoute,
|
||||
}
|
||||
|
||||
const AdminBookingsRouteWithChildren = AdminBookingsRoute._addFileChildren(
|
||||
AdminBookingsRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AvailabilityRoute: AvailabilityRoute,
|
||||
EnquiryRoute: EnquiryRoute,
|
||||
EventsRoute: EventsRoute,
|
||||
KanbanRoute: KanbanRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
LogoutRoute: LogoutRoute,
|
||||
AdminBookingsRoute: AdminBookingsRouteWithChildren,
|
||||
AdminEnquiriesRoute: AdminEnquiriesRoute,
|
||||
BookingFormTokenRoute: BookingFormTokenRoute,
|
||||
BookingBookingIdRoute: BookingBookingIdRoute,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
16
apps/app/src/router.tsx
Normal file
16
apps/app/src/router.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
129
apps/app/src/specs.md
Normal file
129
apps/app/src/specs.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Seminarhof — product backlog
|
||||
|
||||
Status legend: ✅ done · 🟡 partial · ⬜ todo · 🔮 phase 2
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped
|
||||
|
||||
- **Enquiry table.** Public enquiries land in `enquiry` (not `booking`). Admin
|
||||
view at `/admin/enquiries` with approve / waitlist / decline.
|
||||
- **Approve flow.** Requires start + end date and a client — either pick an
|
||||
existing one (searchable autosuggest) or create a new one (email required,
|
||||
defaults to the enquiry contact email; rejects duplicate emails). Creates a
|
||||
`reserved` booking linked via `booking.enquiry_id`.
|
||||
- **`organization` → `client` rename** (+ `client_member` already models
|
||||
multiple login users per client; one is seeded from the enquiry email).
|
||||
- **`course` → `event` rename** across the codebase.
|
||||
- **Enquiry required fields trimmed:** event name + contact email only;
|
||||
privacy-policy consent only (no T&C checkbox).
|
||||
- **Booking-detail "Enquiry" milestone** sources its date from the enquiry's
|
||||
real submission time, not the approval timestamp.
|
||||
|
||||
---
|
||||
|
||||
## Backlog (epics)
|
||||
|
||||
### E1 · Calendar / availability
|
||||
- ✅ **1.1** Hide `enquiry` status on public availability — show it to
|
||||
admins/owners only. `getAvailability` reads the optional session and filters
|
||||
out enquiry-state bookings for non-staff; the legend drops the enquiry swatch
|
||||
when no such day is present.
|
||||
- ✅ **1.2** Half-house: render the day cell **split by a diagonal line** (two
|
||||
triangles, one per half-house booking) instead of the current dot. On hover,
|
||||
show **both** events being held that day. `getAvailability` returns a
|
||||
per-day `bookings[]` (deterministic order); `availability.tsx` renders the
|
||||
diagonal `linear-gradient` split when two half-house bookings share a day and
|
||||
lists both in the tooltip. Single half-house days keep the corner dot.
|
||||
- ⬜ **1.3** Hover → rich detail card for bookings that are **reserved AND
|
||||
public**.
|
||||
- ⬜ **1.4** Waitlist dot in the top-right corner of the day cell. *(Pairs with
|
||||
E6 — the wishlist/waitlist data.)*
|
||||
- 🔮 **1.5** Google Calendar auto-sync. **Phase 2.**
|
||||
|
||||
### E2 · Actions hub + transition safety ✅
|
||||
- ✅ **2.1** All booking actions consolidated into one header hub
|
||||
(`BookingActions.tsx`), beside Cancel booking. `BookingStatusPanel` is now
|
||||
display-only (timeline + next-action alert).
|
||||
- ✅ **2.2** Every transition goes through a two-click `ConfirmButton`
|
||||
(`ConfirmButton.tsx`): first click arms ("Confirm?", red); second commits;
|
||||
auto-reverts after 3s. Replaced the raw `window.confirm` for cancel too.
|
||||
|
||||
### E3 · Contract form + invoicing *(big rock; status-model redesign approved)*
|
||||
- 🟡 **3.1** Binding booking form scoped to a booking — **core fields shipped**.
|
||||
Client opens a **passwordless magic link** (`/booking-form/$token`), lands
|
||||
logged-in as a provisioned `client` user, and fills name/company, billing
|
||||
address, phone, website, payment method (**Cash / bank transfer**), event
|
||||
basics + AGB/privacy consents. Submit is legally binding (stamps
|
||||
`contractResponse='approved'` + `agbAcceptedAt`/`privacyAcceptedAt`). Token =
|
||||
a short-lived signed JWT via **`@pikku/jose`** (no token table). Admin mints
|
||||
the link from the booking actions hub. *Deferred to E7/E5: the priced add-on
|
||||
groups (extras / room equipment / materials) that feed the final invoice.*
|
||||
- ✅ **3.2** Sending the contract → recorded response / binding. *(Lifecycle
|
||||
work — `recordContractResponse` + the form submit both mark it binding.)*
|
||||
- ⬜ **3.3** Generate a **preview invoice PDF** from the form → email to client
|
||||
+ admin → upload into files. *(Depends on E5 — blocked.)*
|
||||
- ✅ **3.4** 14-day deadline for signing + sending the deposit. *(Lifecycle
|
||||
cron: deposit `dueOn=+14d`, day-7 reminder, day-14 overdue flag.)*
|
||||
- ⬜ **3.5** On bank receipt, admin uploads the **official invoice** and marks
|
||||
it paid → *deposit paid*. *(Mark-paid done via `recordDepositReceived`; the
|
||||
upload depends on E5 — blocked.)*
|
||||
- ✅ **3.6** **Status-model redesign** — done by the lifecycle work: enum is
|
||||
`enquiry → reserved → confirmed → ended` (+ `cancelled`); contract-signed +
|
||||
deposit-paid are sub-milestones inside `reserved`, shown as a timeline in
|
||||
`BookingStatusPanel`.
|
||||
|
||||
*Auth note:* the magic link issues the same DB-session as password login
|
||||
(reuses `lib/session.ts`); `app_user` is reused by email, `client_member`
|
||||
links the user to the client org. `consumeBookingFormLink` rejects
|
||||
cancelled/ended bookings and re-checks org membership.
|
||||
|
||||
### E4 · Admin dashboard ✅
|
||||
- ✅ Required-actions hub at `/admin` (the admin landing): four "needs action"
|
||||
cards — pending enquiries (with a waitlist badge), contracts to send,
|
||||
contracts awaiting response, deposits outstanding — plus an "upcoming events"
|
||||
column (confirmed, next 30 days). One read-only `getAdminDashboard` function
|
||||
(`pikkuFunc`, `isAdminOrOwner`) aggregates the buckets from existing booking /
|
||||
enquiry / invoice / contract data; each card row links to its workspace
|
||||
(enquiries list or booking detail). "Deposits outstanding" ≠ overdue — the
|
||||
14-day deadline lands with E7.
|
||||
|
||||
### E5 · File uploads *(infra prerequisite for E3.3 + event images)*
|
||||
- ⬜ Upload invoices.
|
||||
- ⬜ Upload public event images.
|
||||
|
||||
### E6 · Multi-date / wishlist enquiries
|
||||
- ⬜ **6.1** Enquiry form allows multiple date options, sorted by priority.
|
||||
- ⬜ **6.2** "Wishlist" path when dates conflict: instead of approve→booking,
|
||||
keep it as a wishlist with its own workspace table. *(Builds on the existing
|
||||
`waitlisted_at`.)*
|
||||
|
||||
### E7 · Post-confirmation editing rules + `finalizing` state
|
||||
- ⬜ **New lifecycle state `finalizing`** (badge "Finalizing"), entered
|
||||
automatically **14 days before** start: `confirmed → finalizing → ended`.
|
||||
Add the enum value, a `finalizing_at` column, and the auto-transition (cron).
|
||||
- ⬜ After *confirmed*, participants (count) / rooms (allocation) / diet are
|
||||
editable up to **14 days** before the event starts.
|
||||
- ⬜ Entering `finalizing` (the 14-day mark) → auto-create a project/base
|
||||
invoice.
|
||||
- ⬜ Edits after the 14-day mark are still allowed but produce a **differing**
|
||||
(delta) invoice.
|
||||
- ⬜ Allergies remain editable until the event ends, with no invoice change.
|
||||
|
||||
### E8 · Rename `completed` → `ended` ✅
|
||||
- ✅ Status value `completed` → `ended` and column `completed_at` → `ended_at`,
|
||||
end-to-end (DB-first). Enum now:
|
||||
`enquiry → reserved → confirmed → ended` (+ `cancelled`); `finalizing`
|
||||
slots in before `ended` later via E7.
|
||||
|
||||
### E9 · Unified public site chrome
|
||||
- ⬜ Make the public enquiry / events / availability pages match the
|
||||
seminarhof-drawehn.de header + footer for one cohesive website/app feel.
|
||||
|
||||
---
|
||||
|
||||
## Next up
|
||||
|
||||
Quick wins **E8** + **E2** are shipped. Suggested next sequence (per epic
|
||||
notes above): **E5** (file uploads — unblocks E3.3) → **E3** (contract +
|
||||
invoicing, incl. status-model redesign) → **E4** (dashboard) → **E6 / E7 / E9**.
|
||||
71
apps/app/src/theme.css
Normal file
71
apps/app/src/theme.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Seminarhof Drawehn brand theme — global CSS variables + font import.
|
||||
*
|
||||
* Source palette extracted from seminarhof-drawehn.de Elementor kit:
|
||||
* --brand-plum (secondary), --brand-gold (accent), --brand-text, --brand-bg.
|
||||
* Mantine theme (theme.ts) consumes the same hex codes via createTheme.
|
||||
*/
|
||||
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--brand-plum: #88557f;
|
||||
--brand-plum-dark: #4e3149;
|
||||
--brand-pink: #e9c8e3;
|
||||
--brand-gold: #ffce00;
|
||||
--brand-gold-soft: #fee162;
|
||||
--brand-peach: #ffbc7d;
|
||||
--brand-text: #181818;
|
||||
--brand-muted: #989499;
|
||||
--brand-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-text);
|
||||
font-family: 'Open Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* AppShell sidebar NavLink — brand-aligned resting / hover / active states.
|
||||
Active rows get a 3 px inset plum bar plus a soft plum 14% wash. */
|
||||
.mantine-NavLink-root {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--brand-text);
|
||||
position: relative;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.mantine-NavLink-root:hover {
|
||||
background-color: rgba(136, 85, 127, 0.08);
|
||||
}
|
||||
|
||||
.mantine-NavLink-root[data-active] {
|
||||
background-color: rgba(136, 85, 127, 0.14);
|
||||
color: var(--brand-plum-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mantine-NavLink-root[data-active]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand-plum);
|
||||
}
|
||||
|
||||
.mantine-NavLink-label {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
/* Dashboard action/upcoming rows — subtle hover affordance. */
|
||||
.dash-row {
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.dash-row:hover {
|
||||
background-color: rgba(136, 85, 127, 0.08);
|
||||
}
|
||||
1
apps/app/src/theme.ts
Normal file
1
apps/app/src/theme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { theme, brand } from '@project/mantine-theme'
|
||||
Reference in New Issue
Block a user