chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-22 00:17:20 +02:00
commit fe01649d39
161 changed files with 18517 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
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, mKey } from '@/i18n/messages'
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
labelKey: string
roles: Role[]
}
type Section = {
titleKey: string
items: NavItem[]
}
const SECTIONS: Section[] = [
{
titleKey: 'common.nav.sections.workspace',
items: [
{ to: '/admin', labelKey: 'common.nav.items.dashboard', roles: ['admin', 'owner'] },
{ to: '/admin/bookings', labelKey: 'common.nav.items.bookings', roles: ['admin', 'owner'] },
{ to: '/admin/enquiries', labelKey: 'common.nav.items.enquiries', roles: ['admin', 'owner'] },
{ to: '/kanban', labelKey: 'common.nav.items.kanban', roles: ['admin', 'owner'] },
],
},
{
titleKey: 'common.nav.sections.public',
items: [
{ to: '/availability', labelKey: 'common.nav.items.availability', roles: ['client', 'admin', 'owner'] },
{ to: '/enquiry', labelKey: 'common.nav.items.enquiry', roles: ['admin', 'owner'] },
{ to: '/events', labelKey: '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 }}>
{mKey(`common.nav.role.${user.role}` as any)}
</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) => {
const visible = section.items.filter((n) => n.roles.includes(role))
if (visible.length === 0) return null
return (
<Stack key={section.titleKey} gap={4}>
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={600}
px="sm"
mb={4}
style={{ letterSpacing: '0.08em' }}
>
{asI18n(`${mKey(section.titleKey as any)}`)}
</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={mKey(item.labelKey as any)}
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>
)
}