470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|