Relaunch: Station Öffnungszeiten live
Vierter Eintrag der Funktionen-Gruppe (nach der News-Pinnwand, „Bald verfügbar“, ohne Preis):
- Website: Bäckerei Sonnenschein mit großer Status-Pille und Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“
(gleitet per transform ein, der Inhalt rückt nach), Desktop und Handy.
- Handy-Ebene: Finger tippt „Hinweis-Leiste anlegen“ (HINWEIS_PUNKT, Platzhalter bis zur Aufnahme).
- Uhr-Regler: echter Schieberegler unter dem MacBook (inaktiv: aria-hidden + inert) und in der Handy-Karte.
- ZeitenSteuerung (setzeUhr(null) beim Verlassen), lazy-Chunks, Kundenportal importiert aus ablauf.ts nur Typen.
- Pins in relaunch-kundenportal.test.tsx auf neun Stationen angepasst (Als App = Marke 8), Spezifikations-Zähler mitgezogen.
ACHTUNG: public/relaunch/portal/phone-12-zeiten-{390,780}.webp sind vorläufig Kopien von phone-9-termine
(damit Build und Tests laufen); Task 36 ersetzt sie durch die echte Aufnahme.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7147fcd30
commit
a23f2e52c2
14 files changed
+1234
-56
No files matched your search
@@ -18,6 +18,7 @@ import type { TerminZustand } from "./demos/termin/ablauf";
|
||||
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||
import type { ChatZustand } from "./demos/chat/ablauf";
|
||||
import type { ZeitenZustand } from "./demos/zeiten/ablauf";
|
||||
import "./kundenportal.css";
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ import "./kundenportal.css";
|
||||
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
|
||||
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
|
||||
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
|
||||
* Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine
|
||||
* Die Stationen 5 bis 8 (Terminplaner, Website-Analyse, News-Pinnwand, Öffnungszeiten live) zeigen statt eines Bildes eine
|
||||
* Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation
|
||||
* 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht.
|
||||
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
|
||||
@@ -67,7 +68,7 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
|
||||
alt,
|
||||
});
|
||||
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||
type DemoArt = "termin" | "heatmap" | "pinnwand" | "zeiten";
|
||||
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
|
||||
type LiveArt = "vorschau" | "chat";
|
||||
type LadeArt = DemoArt | LiveArt;
|
||||
@@ -94,9 +95,9 @@ type Funktion = {
|
||||
adresse?: string;
|
||||
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||
beschreibung?: string;
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
|
||||
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose, bei Öffnungszeiten live der Uhr-Regler). */
|
||||
hinweis?: string;
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026). */
|
||||
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026; Öffnungszeiten live bis zum Portal-Livegang). */
|
||||
bald?: boolean;
|
||||
};
|
||||
|
||||
@@ -104,6 +105,8 @@ const BaldMarke = ({ f }: { f: Funktion }) => (f.bald ? <span className="kp-bald
|
||||
|
||||
/** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */
|
||||
const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
|
||||
/** Mitte des Knopfes "Hinweis-Leiste anlegen" im Portal-Screenshot der Station Öffnungszeiten live (Platzhalter bis zur Aufnahme phone-12-zeiten). */
|
||||
const HINWEIS_PUNKT = { x: 201, y: 740 };
|
||||
|
||||
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
|
||||
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
|
||||
@@ -191,6 +194,16 @@ const FUNKTIONEN: Funktion[] = [
|
||||
alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der Website.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oeffnungszeiten",
|
||||
titel: "Öffnungszeiten live",
|
||||
demo: "zeiten",
|
||||
bald: true,
|
||||
adresse: "baeckerei-sonnenschein.example",
|
||||
beschreibung:
|
||||
"Beispiel-Website einer Bäckerei mit Öffnungszeiten: Der Status „Jetzt geöffnet“ folgt der Uhrzeit, danach kündigt eine Hinweis-Leiste den Betriebsurlaub an",
|
||||
handy: { name: "phone-12-zeiten", alt: "Öffnungszeiten im Kundenportal bearbeiten" },
|
||||
},
|
||||
{
|
||||
id: "als-app",
|
||||
titel: "Als App",
|
||||
@@ -239,12 +252,16 @@ const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
|
||||
const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
|
||||
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
||||
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
|
||||
const ZeitenSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ZeitenSteuerung })));
|
||||
const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
|
||||
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
|
||||
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
|
||||
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
|
||||
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
|
||||
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
|
||||
const ZeitenSeite = lazyMitVorladen(() => import("./demos/zeiten/ZeitenSeite"));
|
||||
const ZeitenHandy = lazyMitVorladen(() => import("./demos/zeiten/ZeitenHandy"));
|
||||
const ZeitenRegler = lazyMitVorladen(() => import("./demos/zeiten/ZeitenRegler"));
|
||||
|
||||
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
||||
const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
@@ -254,6 +271,7 @@ const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||
zeiten: [ZeitenSteuerung.vorladen, ZeitenSeite.vorladen, ZeitenHandy.vorladen, ZeitenRegler.vorladen],
|
||||
};
|
||||
|
||||
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
||||
@@ -263,6 +281,7 @@ const PLATZHALTER: Record<LadeArt, string> = {
|
||||
termin: "#f6f1ea",
|
||||
heatmap: "#141414",
|
||||
pinnwand: "#f7f2e6",
|
||||
zeiten: "#f7f0e4",
|
||||
};
|
||||
const Platzhalter = ({ art }: { art: LadeArt }) => (
|
||||
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
|
||||
@@ -331,6 +350,7 @@ const Buehne = () => {
|
||||
const [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||
const [zeiten, setZeiten] = useState<ZeitenZustand | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
@@ -488,6 +508,14 @@ const Buehne = () => {
|
||||
{demoEingehaengt("pinnwand") ? (
|
||||
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
|
||||
) : null}
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<ZeitenSteuerung
|
||||
laeuft={lauf.laeuft && aktivDemo === "zeiten"}
|
||||
statisch={lauf.statisch}
|
||||
aktiv={aktivDemo === "zeiten"}
|
||||
onZustand={setZeiten}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
|
||||
<div className="kp-spur" aria-hidden="true">
|
||||
@@ -596,6 +624,7 @@ const Buehne = () => {
|
||||
{f.demo === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
||||
{f.demo === "zeiten" ? <ZeitenSeite zustand={zeiten ?? undefined} variante="desktop" /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -625,6 +654,7 @@ const Buehne = () => {
|
||||
<Suspense fallback={null}>
|
||||
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||
{f.demo === "zeiten" && zeiten ? <ZeitenHandy zustand={zeiten} punkt={HINWEIS_PUNKT} /> : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -664,6 +694,19 @@ const Buehne = () => {
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Öffnungszeiten live: der Uhr-Regler (echter Schieberegler), nur bei aktiver Station sichtbar und bedienbar */}
|
||||
<div
|
||||
className="kp-regler"
|
||||
data-an={aktivDemo === "zeiten" ? "ja" : "nein"}
|
||||
aria-hidden={aktivDemo !== "zeiten" || undefined}
|
||||
{...(aktivDemo !== "zeiten" ? INERT : null)}
|
||||
>
|
||||
{demoEingehaengt("zeiten") ? (
|
||||
<Suspense fallback={null}>
|
||||
<ZeitenRegler zustand={zeiten ?? undefined} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
|
||||
<p
|
||||
key={f.id}
|
||||
@@ -803,6 +846,26 @@ const PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Öffnungszeiten-Karte: die Website im Handy, darunter der Uhr-Regler (Ziehen ändert Status und Uhrzeit auf der Website). */
|
||||
const ZeitenKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("zeiten");
|
||||
const [z, setZ] = useState<ZeitenZustand | null>(null);
|
||||
return (
|
||||
<DemoKarteRahmen
|
||||
f={f}
|
||||
geraetRef={k.ref}
|
||||
nachher={
|
||||
<div className="kp-karte__regler">
|
||||
<Suspense fallback={null}>{k.gesehen ? <ZeitenRegler zustand={z ?? undefined} kompakt /> : null}</Suspense>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{k.gesehen ? <ZeitenSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
|
||||
{k.gesehen ? <ZeitenSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||
</DemoKarteRahmen>
|
||||
);
|
||||
};
|
||||
|
||||
/** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
|
||||
const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
const k = useKarte("heatmap");
|
||||
@@ -825,13 +888,14 @@ const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||
|
||||
const HandyReihe = () => (
|
||||
<div className="kp-reihe">
|
||||
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||
<WischReihe ariaLabel="Neun Funktionen des Kundenportals, seitlich wischen">
|
||||
{FUNKTIONEN.map((f) => {
|
||||
if (f.lang) return <LangeKarte key={f.id} f={f} />;
|
||||
if (f.live === "chat") return <ChatKarte key={f.id} f={f} />;
|
||||
if (f.demo === "termin") return <TerminKarte key={f.id} f={f} />;
|
||||
if (f.demo === "heatmap") return <HeatmapKarte key={f.id} f={f} />;
|
||||
if (f.demo === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
|
||||
if (f.demo === "zeiten") return <ZeitenKarte key={f.id} f={f} />;
|
||||
return (
|
||||
<div key={f.id} className="kp-karte">
|
||||
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
|
||||
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
|
||||
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
|
||||
import { useChatAblauf, type ChatZustand } from "./chat/ablauf";
|
||||
import { useZeitenAblauf, type ZeitenZustand } from "./zeiten/ablauf";
|
||||
|
||||
/**
|
||||
* Taktgeber der Demos (drei Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Taktgeber der Demos (vier Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
|
||||
* Sie liegen in einem eigenen Chunk, damit die Startseite die Ablauf-Logik erst lädt, wenn die Kundenportal-Bühne
|
||||
* eine Demo-Station fast erreicht (Spezifikation 4). Die Bühne hält den gemeldeten Zustand und gibt denselben
|
||||
* Zustand an Website, Handy-Ebene und Diagnose.
|
||||
@@ -51,3 +52,19 @@ export const HeatmapSteuerung = memo(function HeatmapSteuerung({
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
|
||||
/** `aktiv`: Station ist die aktive; sonst geht eine Bedienung des Uhr-Reglers zurück an den Ablauf (beim nächsten Mal von vorn). */
|
||||
export const ZeitenSteuerung = memo(function ZeitenSteuerung({
|
||||
laeuft,
|
||||
statisch,
|
||||
aktiv,
|
||||
onZustand,
|
||||
}: Props<ZeitenZustand> & { aktiv: boolean }) {
|
||||
const zustand = useZeitenAblauf(laeuft, statisch);
|
||||
const { setzeUhr } = zustand;
|
||||
useEffect(() => {
|
||||
if (!aktiv) setzeUhr(null);
|
||||
}, [aktiv, setzeUhr]);
|
||||
useEffect(() => onZustand(zustand), [zustand, onZustand]);
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DemoZeiger } from "../gemeinsam";
|
||||
import { HINWEIS_ANLEGEN_PUNKT, type ZeitenZustand } from "./ablauf";
|
||||
|
||||
/** Wo der Finger wartet, bevor er zum Knopf gleitet (unten rechts, neben dem Home-Balken). */
|
||||
const RUHE = { x: 318, y: 842 };
|
||||
|
||||
// Inline, damit die Ebene die Fläche genau überdeckt und nichts anklickt, auch ohne geladenes CSS.
|
||||
const EBENE = { position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden" } as const;
|
||||
|
||||
type Props = {
|
||||
zustand: ZeitenZustand;
|
||||
/** Mitte des Knopfes „Hinweis-Leiste anlegen“ im Portal-Bild; Standard: HINWEIS_ANLEGEN_PUNKT. */
|
||||
punkt?: { x: number; y: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Live-Ebene über dem Portal-Screenshot im iPhone der Bühne (402 x 874, transparent, pointer-events none):
|
||||
* in Phase „tippen“ gleitet ein Finger auf „Hinweis-Leiste anlegen“ und tippt; danach gleitet auf der Website die Leiste ein.
|
||||
* Davor (Uhr läuft) wartet der Finger unsichtbar in der Ruhelage, damit er beim Start von dort zum Knopf gleitet.
|
||||
*/
|
||||
const ZeitenHandy = ({ zustand, punkt = HINWEIS_ANLEGEN_PUNKT }: Props) => {
|
||||
const amKnopf = zustand.phase === "tippen" || zustand.phase === "leiste";
|
||||
const ziel = amKnopf ? punkt : RUHE;
|
||||
return (
|
||||
<div style={EBENE} aria-hidden="true" data-phase={zustand.phase}>
|
||||
<DemoZeiger x={ziel.x} y={ziel.y} art="finger" klick={zustand.klick} sichtbar={zustand.phase === "tippen"} groesse={1.5} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenHandy;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useId } from "react";
|
||||
import { ZEITEN_START, ZEIT_MAX, ZEIT_MIN, ZEIT_SCHRITT, formatUhr, type ZeitenZustand } from "./ablauf";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Der Uhr-Regler unter dem MacBook (Station „Öffnungszeiten live“): ein echter Schieberegler (input type=range) mit Label,
|
||||
* der Uhrzeit und dem Statussatz der Bäckerei zu dieser Uhrzeit. Wer ihn anfasst, hält den Automatiklauf an;
|
||||
* die Website zeigt dann den Status der gewählten Uhrzeit. Normaler Seitenmaßstab mit den Seitentoken, hell und dunkel.
|
||||
* `kompakt` ist für die Handy-Karte (schmal, bis 320 px).
|
||||
* Der Statussatz meldet sich Screenreadern (aria-live) nur nach einer Bedienung, nicht beim Automatiklauf.
|
||||
*/
|
||||
|
||||
type Props = { zustand?: ZeitenZustand; kompakt?: boolean };
|
||||
|
||||
const ZeitenRegler = ({ zustand = ZEITEN_START, kompakt = false }: Props) => {
|
||||
const id = useId();
|
||||
const { uhr, status, bedient } = zustand;
|
||||
return (
|
||||
<div className={`zr${kompakt ? " zr--kompakt" : ""}`} data-bedient={bedient ? "ja" : "nein"}>
|
||||
<div className="zr__kopf">
|
||||
<label className="zr__titel" htmlFor={id}>
|
||||
Uhrzeit testen
|
||||
</label>
|
||||
<span className="zr__uhr" aria-hidden="true">
|
||||
{formatUhr(uhr)}
|
||||
{" "}Uhr
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
className="zr__regler"
|
||||
type="range"
|
||||
min={ZEIT_MIN}
|
||||
max={ZEIT_MAX}
|
||||
step={ZEIT_SCHRITT}
|
||||
value={uhr}
|
||||
aria-valuetext={`${formatUhr(uhr)} Uhr, ${status.kurz}`}
|
||||
onChange={(e) => zustand.setzeUhr(Number(e.target.value))}
|
||||
/>
|
||||
<p className="zr__status" data-offen={status.offen ? "ja" : "nein"} aria-live={bedient ? "polite" : "off"} aria-atomic="true">
|
||||
<i className="zr__punkt" aria-hidden="true" />
|
||||
{status.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenRegler;
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { Logo, Pfeil, Stempel } from "../baeckerei/teile";
|
||||
import { HINWEIS_TEXT, ZEITEN_START, type ZeitenZustand } from "./ablauf";
|
||||
import "../baeckerei/baeckerei.css";
|
||||
import "./zeiten.css";
|
||||
|
||||
/**
|
||||
* Die Beispiel-Website der Station „Öffnungszeiten live“: wieder die erfundene „Bäckerei Sonnenschein“ (Spezifikation 10),
|
||||
* hier mit großer Status-Pille („Jetzt geöffnet · schließt um 18 Uhr“) und der Hinweis-Leiste „Betriebsurlaub 12.–26. Oktober“,
|
||||
* die oben einschiebt, sobald der Inhaber sie im Portal anlegt (Phase „leiste“; die Seite rückt per transform nach).
|
||||
* Demo-Datum: Montag, 5. Oktober 2026 (Mo bis Fr 6 bis 18 Uhr), die Uhrzeit kommt aus dem Zustand.
|
||||
* Desktop = 1512 x 893 (MacBook-Fläche), Handy = 402 x 874 (oben 59 / unten 34 frei für Statusleiste und Home-Balken).
|
||||
* Eigenes Wurzelelement ohne .bk-inhalt: die Seite läuft nicht mit dem Scrollfortschritt der Live-Vorschau mit.
|
||||
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
|
||||
*/
|
||||
|
||||
const FOTO = "/relaunch/demos/baeckerei/roggenbrot-600.webp";
|
||||
|
||||
/** Wie in der Live-Vorschau: Montag bis Freitag 6 bis 18 Uhr; der Demo-Tag ist ein Montag. */
|
||||
const ZEITEN = [
|
||||
{ tage: "Mo – Fr", zeit: "6:00 – 18:00 Uhr", heute: true },
|
||||
{ tage: "Sa", zeit: "6:00 – 13:00 Uhr" },
|
||||
{ tage: "So", zeit: "7:00 – 11:00 Uhr" },
|
||||
];
|
||||
|
||||
const MENUE = ["Sortiment", "Backstube", "Café", "Torten", "Kontakt"];
|
||||
const BANDWOERTER = ["Roggenbrot", "Dinkelvollkorn", "Buttercroissant", "Laugenbrezel", "Zimtschnecke", "Zwetschgenkuchen"];
|
||||
|
||||
const Karte = () => (
|
||||
<div className="zt-karte">
|
||||
<span className="bk-dach">Öffnungszeiten</span>
|
||||
{ZEITEN.map((z) => (
|
||||
<div key={z.tage} className={`zt-zeile${z.heute ? " is-heute" : ""}`}>
|
||||
<span>{z.tage}</span>
|
||||
<span>{z.zeit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||||
type Props = { zustand?: ZeitenZustand; variante: DemoVariante };
|
||||
|
||||
/** Die Website auf der MacBook-Fläche (desktop) oder in der Handy-Fassung (handy). */
|
||||
const ZeitenSeite = ({ zustand = ZEITEN_START, variante }: Props) => {
|
||||
const desktop = variante === "desktop";
|
||||
const { status } = zustand;
|
||||
return (
|
||||
<div
|
||||
className="bk-seite zt-seite"
|
||||
lang="de"
|
||||
aria-hidden="true"
|
||||
data-variante={variante}
|
||||
data-phase={zustand.phase}
|
||||
data-laeuft={zustand.laeuft ? "ja" : "nein"}
|
||||
>
|
||||
<div className="zt-leiste">
|
||||
<i className="zt-leiste__punkt" />
|
||||
{HINWEIS_TEXT}
|
||||
</div>
|
||||
|
||||
<div className="zt-inhalt">
|
||||
<div className="bk-kopf zt-kopf">
|
||||
<div className="bk-logo">
|
||||
<Logo />
|
||||
<span className="bk-logo__text">
|
||||
<span className="bk-logo__name">Sonnenschein</span>
|
||||
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
|
||||
</span>
|
||||
</div>
|
||||
{desktop ? (
|
||||
<>
|
||||
<div className="bk-menue">
|
||||
{MENUE.map((m) => (
|
||||
<span key={m}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="bk-knopf bk-knopf--dunkel bk-knopf--klein">Vorbestellen</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="zt-burger">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="zt-held">
|
||||
<div className="zt-held__text">
|
||||
<div className="zt-pille" data-offen={status.offen ? "ja" : "nein"}>
|
||||
<i className="zt-pille__punkt" />
|
||||
{/* der Text wechselt nur, wenn sich der Status ändert; mit Bewegung blendet er dann neu ein */}
|
||||
<span key={status.text} className="zt-pille__text">
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bk-held__titel zt-titel">
|
||||
Frisch gebacken.
|
||||
<br />
|
||||
<em>Jeden Morgen.</em>
|
||||
</div>
|
||||
<p className="bk-held__lead">
|
||||
Brot, Brötchen und Kuchen aus unserer Backstube in der Altstadt – mit Sauerteig, viel Zeit und Mehl aus der Region.
|
||||
</p>
|
||||
{desktop ? (
|
||||
<div className="bk-held__knoepfe">
|
||||
<span className="bk-knopf bk-knopf--dunkel">
|
||||
Sortiment ansehen <Pfeil />
|
||||
</span>
|
||||
<span className="bk-knopf bk-knopf--rand">Torte bestellen</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{desktop ? (
|
||||
<div className="bk-held__bild">
|
||||
<img src={FOTO} width={600} height={556} alt="" decoding="async" />
|
||||
<Karte />
|
||||
<Stempel />
|
||||
</div>
|
||||
) : (
|
||||
<Karte />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{desktop ? (
|
||||
<div className="bk-band">
|
||||
{BANDWOERTER.map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeitenSeite;
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Station „Öffnungszeiten live“: Beispiel-Website der Bäckerei Sonnenschein mit großer Status-Pille (.zt-*) und der Uhr-Regler
|
||||
* unter dem MacBook (.zr-*). Grundlage ist baeckerei.css (.bk-seite mit den Variablen --bk-*, Logo, Menü, Knöpfe); die Seite hat
|
||||
* ein eigenes Wurzelelement ohne .bk-inhalt, sonst liefe sie mit --kp-v (Scrollfortschritt der Live-Vorschau) davon.
|
||||
* Desktop 1512 x 893, Handy 402 x 874 (oben 59 / unten 34 frei).
|
||||
* Im MacBook liegt das iPhone über der Fläche ab etwa x 1150 und y 410: dort steht nichts Wichtiges (Pille und Öffnungszeiten
|
||||
* links davon, die Hinweis-Leiste oben, oberhalb von y 410). Größen in Designpunkten: das MacBook verkleinert auf etwa 46 %,
|
||||
* darum Pille ≥ 30 und Leiste ≥ 24.
|
||||
* Grundzustand statisch; Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
|
||||
*/
|
||||
|
||||
.zt-seite {
|
||||
--zt-leiste-h: 64px;
|
||||
--zt-offen: #3f5e2c;
|
||||
--zt-offen-punkt: #4c9a3c;
|
||||
--zt-offen-ring: #dcebd3;
|
||||
--zt-zu: #8a3a12;
|
||||
--zt-zu-punkt: #c4622d;
|
||||
--zt-zu-ring: #f4dccb;
|
||||
}
|
||||
|
||||
/* ── Hinweis-Leiste: gleitet oben ein, der Inhalt rückt per transform nach ─────────────────────────── */
|
||||
|
||||
.zt-leiste {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: var(--bk-tinte);
|
||||
color: #f6ead6;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.zt-leiste__punkt {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--bk-sonne);
|
||||
box-shadow: 0 0 0 5px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
.zt-inhalt {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-leiste {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.zt-seite[data-phase="leiste"] .zt-inhalt {
|
||||
transform: translateY(var(--zt-leiste-h));
|
||||
}
|
||||
|
||||
/* ── Start: Pille, Titel, Öffnungszeiten ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.zt-held {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 560px;
|
||||
gap: 56px;
|
||||
align-items: center;
|
||||
height: 640px;
|
||||
padding: 0 88px;
|
||||
}
|
||||
.zt-held__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.zt-pille {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 28px 12px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--bk-papier);
|
||||
border: 2px solid var(--bk-linie);
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
color: var(--zt-offen);
|
||||
}
|
||||
.zt-pille__punkt {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--zt-offen-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] {
|
||||
color: var(--zt-zu);
|
||||
}
|
||||
.zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
background: var(--zt-zu-punkt);
|
||||
box-shadow: 0 0 0 6px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-titel {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.zt-karte {
|
||||
position: absolute;
|
||||
left: -56px;
|
||||
bottom: 36px;
|
||||
width: 300px;
|
||||
padding: 18px 22px 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--bk-papier);
|
||||
box-shadow: 0 24px 50px -24px rgb(60 30 5 / 0.45);
|
||||
}
|
||||
.zt-karte .bk-dach {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.zt-zeile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 7px 10px;
|
||||
margin: 0 -10px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: var(--bk-tinte-leise);
|
||||
}
|
||||
.zt-zeile.is-heute {
|
||||
background: var(--bk-sand);
|
||||
color: var(--bk-tinte);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Handy (402 x 874): Statusleiste oben 59 frei, Leiste darunter ─────────────────────────────────── */
|
||||
|
||||
.zt-seite[data-variante="handy"] {
|
||||
--zt-leiste-h: 52px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste {
|
||||
top: 59px;
|
||||
height: 52px;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-leiste__punkt {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 0 4px rgb(242 181 68 / 0.28);
|
||||
}
|
||||
/* die oberen 59 px überdeckt die Statusleiste des iPhones (heller Text): ein dunkler Streifen macht Uhrzeit und Symbole lesbar */
|
||||
.zt-seite[data-variante="handy"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 59px;
|
||||
z-index: 4;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-inhalt {
|
||||
top: 59px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-kopf {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo {
|
||||
gap: 10px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zeichen {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__name {
|
||||
font-size: 27px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-logo__zusatz {
|
||||
margin-top: 3px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
.zt-burger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 26px;
|
||||
}
|
||||
.zt-burger i {
|
||||
display: block;
|
||||
height: 2.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--bk-tinte);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-held {
|
||||
display: block;
|
||||
height: auto;
|
||||
padding: 22px 20px 0;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille {
|
||||
gap: 11px;
|
||||
max-width: 100%;
|
||||
padding: 9px 18px 9px 15px;
|
||||
border-width: 1.5px;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille__punkt {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
box-shadow: 0 0 0 4px var(--zt-offen-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-pille[data-offen="nein"] .zt-pille__punkt {
|
||||
box-shadow: 0 0 0 4px var(--zt-zu-ring);
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-titel {
|
||||
margin: 22px 0 14px;
|
||||
font-size: 62px;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .bk-held__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-karte {
|
||||
position: static;
|
||||
width: auto;
|
||||
margin-top: 26px;
|
||||
border: 1px solid var(--bk-linie);
|
||||
box-shadow: none;
|
||||
}
|
||||
.zt-seite[data-variante="handy"] .zt-zeile {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Uhr-Regler unter dem MacBook (normaler Seitenmaßstab, Seitentoken, hell und dunkel) ───────────── */
|
||||
|
||||
.zr {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 10px 16px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-alt);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
.zr *,
|
||||
.zr *::before,
|
||||
.zr *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.zr__kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.zr__titel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__uhr {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__regler {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
accent-color: var(--accent-text);
|
||||
touch-action: pan-y;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zr__regler:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.zr__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
min-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.zr__punkt {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3d9a4a;
|
||||
}
|
||||
.zr__status[data-offen="nein"] .zr__punkt {
|
||||
background: #c4622d;
|
||||
}
|
||||
.zr--kompakt {
|
||||
max-width: 320px;
|
||||
padding: 10px 14px 12px;
|
||||
}
|
||||
|
||||
/* ── Bewegung (nur transform und opacity, nur ohne reduzierte Bewegung) ────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.zt-leiste {
|
||||
transition:
|
||||
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 400ms ease;
|
||||
}
|
||||
.zt-inhalt {
|
||||
transition: transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.zt-pille__text {
|
||||
animation: zt-wechsel 420ms ease-out both;
|
||||
}
|
||||
}
|
||||
@keyframes zt-wechsel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht Stationen brauchen sonst mehr Platz) */
|
||||
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (neun Stationen brauchen sonst mehr Platz) */
|
||||
@media (max-height: 720px) {
|
||||
/* Oberkante bleibt stehen, wenn die Unterschriften wechseln (sonst würde die zentrierte Liste springen) */
|
||||
.kp-liste {
|
||||
@@ -368,16 +368,22 @@
|
||||
display: grid;
|
||||
}
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kp-diagnose[data-an="ja"],
|
||||
.kp-regler[data-an="ja"],
|
||||
.kp-hinweis[data-an="ja"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Der Regler (Öffnungszeiten live) ist nie höher als die Diagnose, damit die Zelle beim Wechsel nicht springt */
|
||||
.kp-regler {
|
||||
align-self: start;
|
||||
}
|
||||
/* Hinweiskarte im Stil der Diagnose (gleiche Linie, gleiches Padding) */
|
||||
.kp-hinweis {
|
||||
align-self: start;
|
||||
@@ -408,6 +414,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.kp-diagnose,
|
||||
.kp-regler,
|
||||
.kp-hinweis,
|
||||
.kp-beispiel {
|
||||
transition: opacity var(--dur-enter) var(--ease);
|
||||
@@ -539,7 +546,8 @@
|
||||
color: var(--fg-muted);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.kp-karte__diagnose {
|
||||
.kp-karte__diagnose,
|
||||
.kp-karte__regler {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: 1rem;
|
||||
|
||||
Reference in new issue
Block a user