chore: seminarhof customer project
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user