chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-10 23:12:46 +02:00
commit 50a4d93100
188 changed files with 20779 additions and 0 deletions

View 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>
)
}