Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 26s
All checks were successful
Build & Deploy / deploy (push) Successful in 26s
This commit is contained in:
525
src/components/AppShell.tsx
Normal file
525
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ApiError,
|
||||
fetchMyCustomer,
|
||||
fetchProducts,
|
||||
fetchStatusEvents,
|
||||
getSeenAt,
|
||||
loadSession,
|
||||
logout,
|
||||
markNotificationsSeen,
|
||||
sendSampleRequest,
|
||||
type Product,
|
||||
type StatusEvent,
|
||||
} from '../lib/api';
|
||||
import { STATUS_LABEL } from '../lib/status';
|
||||
import { useDialogs } from '../lib/dialogs';
|
||||
import { useWizard } from '../lib/wizard';
|
||||
import OfferForm from './OfferForm';
|
||||
import {
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconClose,
|
||||
IconHelp,
|
||||
IconList,
|
||||
IconMail,
|
||||
IconMenu,
|
||||
IconOffer,
|
||||
IconPen,
|
||||
IconPhone,
|
||||
IconUser,
|
||||
} from './Icons';
|
||||
|
||||
const KONTAKT = { mail: 'hello@skrift.de', tel: '+49 6872 2153' };
|
||||
/** Routen, unter denen der Konfigurator läuft (Start + Direktlinks). */
|
||||
const KONFIGURATOR_PFADE = ['/', '/briefe', '/postkarten', '/unterschriftenservice', '/follow_ups', '/followups', '/follow-ups'];
|
||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||||
|
||||
function Wordmark() {
|
||||
const [logoFehlt, setLogoFehlt] = useState(false);
|
||||
return (
|
||||
<Link to="/" className="sk-wordmark" aria-label="Skrift – Startseite">
|
||||
{logoFehlt ? 'Skrift' : <img src="/logo.svg" alt="Skrift" className="sk-logo" onError={() => setLogoFehlt(true)} />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Konto-Dropdown in der Kopfleiste: Name/Rolle + Abmelden (bzw. Anmelden). */
|
||||
function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const angemeldet = !!loadSession();
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [kunde, setKunde] = useState<{ name: string; email: string; rolle: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!angemeldet) return;
|
||||
fetchMyCustomer()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
setKunde({
|
||||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email,
|
||||
email: c.email,
|
||||
rolle: c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatkunde',
|
||||
});
|
||||
})
|
||||
.catch(() => { /* Anzeige ist unkritisch */ });
|
||||
}, [angemeldet]);
|
||||
|
||||
if (!angemeldet) {
|
||||
return (
|
||||
<Link to="/login" className="sk-btn sk-btn--ghost">
|
||||
Anmelden
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="sk-user" onClick={() => setOffen((v) => !v)} aria-expanded={offen}>
|
||||
<span className="sk-user-avatar"><IconUser size={16} /></span>
|
||||
<span className="sk-user-name">{kunde?.name ?? 'Konto'}</span>
|
||||
<IconChevronDown size={16} className="sk-user-caret" />
|
||||
</button>
|
||||
|
||||
{offen && (
|
||||
<>
|
||||
<div className="sk-menu-backdrop" onClick={() => setOffen(false)} />
|
||||
<div className="sk-popover sk-popover--menu">
|
||||
<div className="sk-menu-head">
|
||||
<div className="sk-menu-name">{kunde?.name}</div>
|
||||
<div className="sk-menu-sub">{kunde?.email}</div>
|
||||
<div className="sk-menu-sub">{kunde?.rolle}</div>
|
||||
</div>
|
||||
<button
|
||||
className="sk-menu-item"
|
||||
onClick={() => { logout(); setOffen(false); navigate('/login', { replace: true }); }}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schlichtes Overlay für Hilfe und Angebotsanfrage. */
|
||||
function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sk-overlay" role="dialog" aria-modal="true" aria-label={title} onClick={onClose}>
|
||||
<div className="sk-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sk-dialog-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="sk-icon-btn" onClick={onClose} aria-label="Schließen">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Konfigurator samt lokalem Cache zurücksetzen (Login bleibt erhalten). */
|
||||
function konfiguratorZuruecksetzen() {
|
||||
if (!window.confirm('Konfigurator zurücksetzen? Alle Eingaben und der lokale Zwischenspeicher werden gelöscht (die Anmeldung bleibt bestehen).')) return;
|
||||
try {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith('skrift.') && k !== 'skrift.session')
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
} catch { /* localStorage nicht verfügbar – dann reicht der Reload */ }
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function Hilfe({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
||||
<p className="sk-note">Sie erreichen uns direkt – wir melden uns werktags am selben Tag.</p>
|
||||
<div className="sk-contact">
|
||||
<a href={`mailto:${KONTAKT.mail}`} className="sk-contact-row">
|
||||
<IconMail />
|
||||
<span>{KONTAKT.mail}</span>
|
||||
</a>
|
||||
<a href={`tel:${KONTAKT.tel.replace(/\s/g, '')}`} className="sk-contact-row">
|
||||
<IconPhone />
|
||||
<span>{KONTAKT.tel}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Rettungsanker bei hängengebliebenem Zustand (z. B. falscher Zeilen-Hinweis). */}
|
||||
<div className="sk-help-reset">
|
||||
<p className="sk-note">Zeigt der Konfigurator unerwartetes Verhalten (z. B. einen falschen Zeilen-Hinweis)? Setzen Sie ihn hier zurück:</p>
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={konfiguratorZuruecksetzen}>
|
||||
Konfigurator zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sk-legal-links">
|
||||
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">AGB</a>
|
||||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutz</a>
|
||||
<a href="https://skrift.de/impressum" target="_blank" rel="noreferrer">Impressum</a>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?: string }) {
|
||||
// vorbelegung = gewähltes Kontakt-Produkt; wird separat übermittelt, nicht im Text.
|
||||
return (
|
||||
<Dialog title="Individuelles Angebot anfordern" onClose={onClose}>
|
||||
<OfferForm produkt={vorbelegung} onClose={onClose} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muster bestellen – kostenlos, mit Produktwahl und Lieferadresse.
|
||||
* variant='page' rendert als eigene Seite (Direktlink /muster) statt als Popup. */
|
||||
export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) {
|
||||
const [produkte, setProdukte] = useState<Product[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
product: '', name: '', email: '', phone: '',
|
||||
street: '', zip: '', city: '', country: 'Deutschland', message: '',
|
||||
});
|
||||
const [datenschutz, setDatenschutz] = useState(false);
|
||||
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||
const [fehler, setFehler] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
.then((ps) => setProdukte(ps.filter((p) => p.type === 'letter' || p.type === 'postcard')))
|
||||
.catch(() => setProdukte([]));
|
||||
if (loadSession()) {
|
||||
fetchMyCustomer()
|
||||
.then((c) => c && setForm((f) => ({
|
||||
...f,
|
||||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name,
|
||||
email: c.email || f.email,
|
||||
phone: c.phone || f.phone,
|
||||
})))
|
||||
.catch(() => { /* optional */ });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const roh = e.target.value;
|
||||
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
||||
};
|
||||
|
||||
async function absenden(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||||
setFehler([]);
|
||||
setStatus('sendet');
|
||||
try {
|
||||
await sendSampleRequest({
|
||||
product: form.product,
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
street: form.street.trim(),
|
||||
zip: form.zip.trim(),
|
||||
city: form.city.trim(),
|
||||
country: form.country,
|
||||
message: form.message.trim() || undefined,
|
||||
});
|
||||
setStatus('fertig');
|
||||
} catch (err) {
|
||||
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage fehlgeschlagen.']);
|
||||
setStatus('formular');
|
||||
}
|
||||
}
|
||||
|
||||
// Rahmen je nach Modus: Popup (Dialog) oder eigene Seite (sk-view).
|
||||
const wrap = (titel: string, inhalt: React.ReactNode) =>
|
||||
variant === 'page'
|
||||
? <div className="sk-view"><h1>{titel}</h1>{inhalt}</div>
|
||||
: <Dialog title={titel} onClose={onClose}>{inhalt}</Dialog>;
|
||||
|
||||
if (status === 'fertig') {
|
||||
return wrap('Muster ist unterwegs', (
|
||||
<>
|
||||
<p>Vielen Dank – Ihr kostenloses Muster wird vorbereitet und verschickt.</p>
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||||
{variant === 'page' ? 'Zur Startseite' : 'Schließen'}
|
||||
</button>
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
const feld = (k: keyof typeof form, label: string, opt = false, type = 'text') => (
|
||||
<div className="sk-field">
|
||||
<label className={`sk-label${opt ? '' : ' sk-label--required'}`} htmlFor={`m-${k}`}>
|
||||
{label} {opt && <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>}
|
||||
</label>
|
||||
<input id={`m-${k}`} className="sk-input" type={type} required={!opt}
|
||||
value={form[k]} onChange={set(k)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const gueltig = form.product && form.name.trim() && istEmail(form.email) && form.phone.trim()
|
||||
&& form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz;
|
||||
|
||||
return wrap('Muster bestellen', (
|
||||
<>
|
||||
<p className="sk-note">Wir schicken Ihnen kostenlos ein Handschrift-Muster zum Anfassen.</p>
|
||||
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label sk-label--required" htmlFor="m-product">
|
||||
Von welchem Produkt möchten Sie ein Muster?
|
||||
</label>
|
||||
<select id="m-product" className="sk-input" required value={form.product} onChange={set('product')}>
|
||||
<option value="" disabled>Bitte wählen …</option>
|
||||
{produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{feld('name', 'Name')}
|
||||
{feld('email', 'E-Mail-Adresse', false, 'email')}
|
||||
{feld('phone', 'Telefon', false, 'tel')}
|
||||
{feld('street', 'Straße und Hausnummer')}
|
||||
{feld('zip', 'PLZ')}
|
||||
{feld('city', 'Stadt')}
|
||||
<div className="sk-field">
|
||||
<label className="sk-label sk-label--required" htmlFor="m-country">Land</label>
|
||||
<select id="m-country" className="sk-input" value={form.country} onChange={set('country')}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="m-message">
|
||||
Nachricht <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<textarea id="m-message" className="sk-input" rows={3} maxLength={1000}
|
||||
placeholder="Wünsche oder Fragen zum Muster?"
|
||||
value={form.message} onChange={set('message')} />
|
||||
</div>
|
||||
|
||||
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
||||
<span>
|
||||
Ich habe die{' '}
|
||||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||||
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||||
Anfrage einverstanden.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{fehler.length > 0 && (
|
||||
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !gueltig}>
|
||||
{status === 'sendet' ? 'Wird gesendet …' : 'Muster anfordern'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { steps, current, goto } = useWizard();
|
||||
const location = useLocation();
|
||||
const { open } = useDialogs();
|
||||
const angemeldet = !!loadSession();
|
||||
|
||||
// Ablauf nur im Konfigurator zeigen (Start + Direktlinks /briefe, /postkarten) –
|
||||
// nicht in Aufträgen/Stammdaten.
|
||||
const zeigeAblauf = KONFIGURATOR_PFADE.includes(location.pathname) && steps.length > 0;
|
||||
|
||||
const item = ({ isActive }: { isActive: boolean }) => `sk-nav-item${isActive ? ' sk-nav-item--active' : ''}`;
|
||||
|
||||
return (
|
||||
<aside className="sk-sidebar">
|
||||
<nav className="sk-nav">
|
||||
<NavLink to="/" className={item} end onClick={onNavigate}>
|
||||
<IconPen />
|
||||
<span>Neuer Auftrag</span>
|
||||
</NavLink>
|
||||
<NavLink to="/auftraege" className={item} onClick={onNavigate}>
|
||||
<IconList />
|
||||
<span>Aufträge</span>
|
||||
</NavLink>
|
||||
<NavLink to="/stammdaten" className={item} onClick={onNavigate}>
|
||||
<IconUser />
|
||||
<span>Stammdaten</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{zeigeAblauf && (
|
||||
<div className="sk-nav-section sk-nav-section--divider">
|
||||
<div className="sk-nav-label">Ablauf</div>
|
||||
<ol className="sk-process">
|
||||
{steps.map((label, i) => {
|
||||
const erledigt = i < current;
|
||||
const klasse = `sk-process-item${
|
||||
i === current ? ' sk-process-item--current' : erledigt ? ' sk-process-item--done' : ''
|
||||
}`;
|
||||
const inhalt = (
|
||||
<>
|
||||
<span className="sk-process-mark">{erledigt ? <IconCheck size={14} /> : i + 1}</span>
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
// Erledigte Schritte sind anklickbar (Rücksprung); der aktuelle und
|
||||
// kommende Schritte bleiben passiv.
|
||||
return (
|
||||
<li key={label}>
|
||||
{erledigt ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${klasse} sk-process-item--clickable`}
|
||||
onClick={() => { goto(i); onNavigate?.(); }}
|
||||
title="Zu diesem Schritt zurückspringen"
|
||||
>
|
||||
{inhalt}
|
||||
</button>
|
||||
) : (
|
||||
<div className={klasse}>{inhalt}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!angemeldet && (
|
||||
<p className="sk-nav-hint">
|
||||
Melden Sie sich an, um Aufträge und Stammdaten zu sehen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="sk-sidebar-foot sk-nav-section--divider">
|
||||
<NavLink to="/muster" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
|
||||
<IconMail />
|
||||
<span>Muster bestellen</span>
|
||||
</NavLink>
|
||||
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('angebot'); onNavigate?.(); }}>
|
||||
<IconOffer />
|
||||
<span>Individuelles Angebot</span>
|
||||
</button>
|
||||
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}>
|
||||
<IconHelp />
|
||||
<span>Hilfe</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigungen speisen sich aus dem Statusverlauf der eigenen Aufträge –
|
||||
* dafür braucht es keine eigene Collection. Als „gelesen" gilt alles, was älter
|
||||
* ist als der zuletzt geöffnete Zeitpunkt (lokal gespeichert).
|
||||
*/
|
||||
function Notifications() {
|
||||
const [events, setEvents] = useState<StatusEvent[]>([]);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [seenAt, setSeenAt] = useState(getSeenAt);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatusEvents()
|
||||
.then(setEvents)
|
||||
.catch(() => {
|
||||
/* Benachrichtigungen sind nicht kritisch */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ungelesen = events.filter((e) => new Date(e.timestamp).getTime() > seenAt).length;
|
||||
|
||||
function umschalten() {
|
||||
const next = !offen;
|
||||
setOffen(next);
|
||||
if (next && ungelesen > 0) {
|
||||
markNotificationsSeen();
|
||||
setSeenAt(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="sk-icon-btn"
|
||||
aria-label={ungelesen ? `${ungelesen} neue Benachrichtigungen` : 'Benachrichtigungen'}
|
||||
onClick={umschalten}
|
||||
>
|
||||
<IconBell />
|
||||
{ungelesen > 0 && <span className="sk-dot" aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
{offen && (
|
||||
<div className="sk-popover">
|
||||
{events.length === 0 ? (
|
||||
<p className="sk-note">Keine Benachrichtigungen.</p>
|
||||
) : (
|
||||
<ul className="sk-notif-list">
|
||||
{events.slice(0, 5).map((e) => (
|
||||
<li key={e.id} className="sk-notif">
|
||||
<div className="sk-notif-title">
|
||||
{e.order?.order_number ?? 'Auftrag'} · {STATUS_LABEL[e.status] ?? e.status}
|
||||
</div>
|
||||
{e.note && <div className="sk-notif-note">{e.note}</div>}
|
||||
<div className="sk-notif-time">
|
||||
{new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||||
new Date(e.timestamp),
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Topbar({ onMenu }: { onMenu: () => void }) {
|
||||
const angemeldet = !!loadSession();
|
||||
return (
|
||||
<header className="sk-topbar">
|
||||
<div className="sk-topbar-left">
|
||||
{/* Menü-Umschalter nur auf schmalen Geräten sichtbar (CSS). */}
|
||||
<button className="sk-icon-btn sk-menu-toggle" aria-label="Menü öffnen" onClick={onMenu}>
|
||||
<IconMenu />
|
||||
</button>
|
||||
<Wordmark />
|
||||
</div>
|
||||
|
||||
<div className="sk-topbar-right">
|
||||
{angemeldet && <Notifications />}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [drawerOffen, setDrawerOffen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { dialog, vorbelegung, close } = useDialogs();
|
||||
|
||||
// Beim Seitenwechsel das mobile Menü schließen.
|
||||
useEffect(() => { setDrawerOffen(false); }, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="sk-app">
|
||||
<Topbar onMenu={() => setDrawerOffen(true)} />
|
||||
|
||||
<div className={`sk-sidebar-wrap${drawerOffen ? ' sk-sidebar-wrap--open' : ''}`}>
|
||||
<Sidebar onNavigate={() => setDrawerOffen(false)} />
|
||||
</div>
|
||||
{drawerOffen && <div className="sk-backdrop" onClick={() => setDrawerOffen(false)} />}
|
||||
|
||||
<main className="sk-content">{children}</main>
|
||||
|
||||
{/* Dialoge zentral – von Seitenleiste und Konfigurator aus geöffnet. */}
|
||||
{dialog === 'hilfe' && <Hilfe onClose={close} />}
|
||||
{dialog === 'angebot' && <Angebot onClose={close} vorbelegung={vorbelegung} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user