This commit is contained in:
egnbkts committed 2026-09-08 09:49:27 +02:00
1 parent 574e07603d
commit cc2b84dcad
27 files changed
+2214 -360

No files matched your search

+2 -2
View File
@@ -55,7 +55,7 @@
.textBlock {
display: flex;
flex-direction: column;
gap: var(--space-5);
gap: var(--space-4);
align-items: center;
}
@@ -107,7 +107,7 @@
@media (min-width: 1024px) {
.section {
padding-block: var(--space-32);
padding-block: var(--space-24);
}
.inner {
+75
View File
@@ -0,0 +1,75 @@
import { CheckCircleIcon } from "@phosphor-icons/react/dist/ssr";
import styles from "./conceptSection.module.css";
interface ConceptPillar {
number: string;
title: string;
points: string[];
}
interface ConceptResults {
heading: string;
intro: string;
points: string[];
}
interface ConceptSectionProps {
heading: string;
lead: string;
pillars: ConceptPillar[];
results: ConceptResults;
}
export default function ConceptSection({
heading,
lead,
pillars,
results,
}: ConceptSectionProps) {
return (
<section className={styles.concept} aria-labelledby="concept-heading">
<div className={styles.inner}>
<div className={styles.intro}>
<h2 id="concept-heading" className={styles.heading}>
{heading}
</h2>
<p className={styles.lead}>{lead}</p>
</div>
<div className={styles.bento}>
{pillars.map((pillar) => (
<article key={pillar.number} className={styles.pillarCard}>
<span className={styles.number}>{pillar.number}</span>
<h3 className={styles.pillarTitle}>{pillar.title}</h3>
<ul className={styles.pointList}>
{pillar.points.map((point) => (
<li key={point} className={styles.point}>
<span className={styles.pointIcon} aria-hidden="true">
<CheckCircleIcon size={16} weight="duotone" />
</span>
{point}
</li>
))}
</ul>
</article>
))}
<article className={styles.resultsCard}>
<h3 className={styles.resultsHeading}>{results.heading}</h3>
<p className={styles.resultsIntro}>{results.intro}</p>
<ul className={styles.pointList}>
{results.points.map((point) => (
<li key={point} className={styles.point}>
<span className={styles.pointIcon} aria-hidden="true">
<CheckCircleIcon size={16} weight="duotone" />
</span>
{point}
</li>
))}
</ul>
</article>
</div>
</div>
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { CheckCircleIcon } from "@phosphor-icons/react/dist/ssr";
import styles from "./maintenanceCleaningSection.module.css";
interface MaintenanceCleaningSectionProps {
heading: string;
lead: string;
requirements: string[];
}
export default function MaintenanceCleaningSection({
heading,
lead,
requirements,
}: MaintenanceCleaningSectionProps) {
return (
<section
className={styles.maintenanceCleaning}
aria-labelledby="maintenance-cleaning-heading"
>
<div className={styles.inner}>
<div className={styles.intro}>
<h3 id="maintenance-cleaning-heading" className={styles.heading}>
{heading}
</h3>
<p className={`lead ${styles.lead}`}>{lead}</p>
</div>
<div className={styles.grid}>
{requirements.map((requirement) => (
<article key={requirement} className={styles.card}>
<span className={styles.iconWrap} aria-hidden="true">
<CheckCircleIcon size={24} weight="duotone" />
</span>
<p className={styles.cardText}>{requirement}</p>
</article>
))}
</div>
</div>
</section>
);
}
+6 -6
View File
@@ -105,19 +105,19 @@
opacity: 0;
transform: translateY(-8px);
transition:
max-height 300ms ease,
opacity 300ms ease,
transform 300ms ease,
visibility 0s linear 300ms;
max-height 200ms ease,
opacity 200ms ease,
transform 200ms ease,
visibility 0s linear 200ms;
background-color: var(--nav-bg);
}
.nav-submenu.open {
transition:
max-height 300ms ease,
max-height 200ms ease,
opacity 300ms ease,
transform 300ms ease,
visibility 0s linear 0s;
visibility 0s linear 0;
}
.nav-submenu-list li a:focus-visible {
+7 -2
View File
@@ -37,13 +37,18 @@ export const NavbarSubMenu: FC<Props> = ({
}) => {
const pathname = usePathname();
const subMenuRef = useRef<HTMLUListElement | null>(null);
const subMenuInnerRef = useRef<HTMLLIElement | null>(null);
const isDesktop = useIsDesktop();
const openMenu = () => setOpenSubmenuId(id);
const closeMenu = () => setOpenSubmenuId(null);
const toggleMenu = () => setOpenSubmenuId(open ? null : id);
useHeightVar(subMenuRef, "--submenu-h", "scroll", open);
// Measure the inner (unclipped) wrapper, not the ul.nav-submenu that
// gets its max-height animated — observing the animated element itself
// causes the ResizeObserver to refire on every step of the transition,
// which makes the animation stutter in Firefox.
useHeightVar(subMenuInnerRef, "--submenu-h", "scroll", open, subMenuRef);
const handleSubmenuClick = (e: MouseEvent<HTMLUListElement>) => {
const target = e.target as HTMLElement;
@@ -79,7 +84,7 @@ export const NavbarSubMenu: FC<Props> = ({
].join(" ")}
onClick={handleSubmenuClick}
>
<li className={styles["nav-submenu-inner"]}>
<li ref={subMenuInnerRef} className={styles["nav-submenu-inner"]}>
<ul className={styles["nav-submenu-list"]}>
{items.map((item) => {
const isActive =
+42
View File
@@ -0,0 +1,42 @@
import styles from "./processSection.module.css";
interface ProcessStep {
number: string;
title: string;
}
interface ProcessSectionProps {
heading: string;
lead: string;
steps: ProcessStep[];
}
export default function ProcessSection({
heading,
lead,
steps,
}: ProcessSectionProps) {
return (
<section className={styles.process} aria-labelledby="process-heading">
<div className={styles.inner}>
<div className={styles.intro}>
<h2 id="process-heading" className={styles.heading}>
{heading}
</h2>
<p className={`lead ${styles.lead}`}>{lead}</p>
</div>
<div className={styles.steps}>
{steps.map((step) => (
<div key={step.number} className={styles.step}>
<span className={styles.stepNumber} aria-hidden="true">
{step.number}
</span>
<span className={styles.stepTitle}>{step.title}</span>
</div>
))}
</div>
</div>
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Image from "next/image";
import Link from "next/link";
import { PhoneIcon, ArrowRightIcon } from "@phosphor-icons/react/dist/ssr";
import { companyData } from "@/data/companyData";
import styles from "./qualityClaimSection.module.css";
interface QualityClaimSectionProps {
heading: string;
textLeft: string;
textRight: string;
image: string;
imageAlt: string;
}
export default function QualityClaimSection({
heading,
textLeft,
textRight,
image,
imageAlt,
}: QualityClaimSectionProps) {
return (
<section
className={styles.qualityClaim}
aria-labelledby="quality-claim-heading"
>
<div className={styles.inner}>
<div className={styles.imageWrapper}>
<Image
src={image}
alt={imageAlt}
fill
className={styles.image}
sizes="(max-width: 1024px) 100vw, 320px"
/>
</div>
<div className={styles.content}>
<h2 id="quality-claim-heading" className={styles.heading}>
{heading}
</h2>
<div className={styles.textGrid}>
<p className={styles.text}>{textLeft}</p>
<p className={styles.text}>{textRight}</p>
</div>
<div className={styles.actions}>
<a
href={`tel:${companyData.contact.phone.value}`}
className="btn btn--tertiary"
>
<span className="btn-icon" aria-hidden="true">
<PhoneIcon size={18} weight="bold" />
</span>
{companyData.contact.phone.display}
</a>
<Link href="/kontakt" className="btn btn--filled">
<span className="btn-icon animate" aria-hidden="true">
<ArrowRightIcon size={18} weight="bold" />
</span>
Kostenloses Angebot sichern
</Link>
</div>
</div>
</div>
</section>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@
/* Desktop: Intro links, Grid rechts — nebeneinander */
@media (min-width: 1024px) {
.benefits {
padding-block: var(--space-36);
padding-block: var(--space-24);
}
.inner {
+116
View File
@@ -0,0 +1,116 @@
.concept {
background: var(--color-background-linear);
padding-block: var(--space-16);
}
.inner {
max-width: var(--container-width);
margin-inline: auto;
padding-inline: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 60ch;
}
.lead strong {
font-weight: var(--font-weight-semibold);
}
.bento {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
}
.pillarCard,
.resultsCard {
background: var(--color-surface-muted, #e6ede9);
border-radius: 12px;
padding: var(--space-5);
}
.number {
display: block;
color: var(--color-accent);
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
}
.pillarTitle {
color: var(--color-accent);
font-size: var(--font-size-m);
padding: 0 0 8px 0;
}
.resultsHeading {
color: var(--color-accent);
font-size: var(--font-size-h2);
padding: 0 0 8px 0;
}
.resultsIntro {
color: var(--color-text-muted);
color: var(--color-accent);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-m);
padding: 0 0 8px 0;
}
.resultsIntro strong {
font-weight: var(--font-weight-semibold);
color: var(--font-heading-color);
}
.pointList {
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style: none;
padding: 0;
margin: 0;
}
.point {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.pointIcon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-accent);
}
/* Tablet */
@media (min-width: 768px) {
.concept {
padding-block: var(--space-24);
}
.bento {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.resultsCard {
grid-column: 1 / -1;
}
}
/* Desktop */
@media (min-width: 1024px) {
.concept {
padding-block: var(--space-24);
}
}
@@ -1,6 +1,6 @@
.section {
background: #ffffff;
padding-bottom: var(--space-16);
padding-block: var(--space-24);
}
.inner {
+24 -8
View File
@@ -4,6 +4,13 @@ import { companyData } from "@/data/companyData";
import styles from "./ContactCtaSection.module.css";
import { contactCtaData, type ContactInfo } from "./ContactCtaData";
interface ContactCtaSectionProps {
heading?: string;
lead?: string;
buttonLabel?: string;
buttonHref?: string;
}
function resolveCompanyContact(
item: Extract<ContactInfo, { type: "company" }>,
) {
@@ -32,8 +39,17 @@ function resolveCompanyContact(
return null;
}
export default function ContactCtaSection() {
const { heading, lead, buttonLabel, buttonHref, contacts } = contactCtaData;
export default function ContactCtaSection({
heading,
lead,
buttonLabel,
buttonHref,
}: ContactCtaSectionProps = {}) {
const resolvedHeading = heading ?? contactCtaData.heading;
const resolvedLead = lead ?? contactCtaData.lead;
const resolvedButtonLabel = buttonLabel ?? contactCtaData.buttonLabel;
const resolvedButtonHref = buttonHref ?? contactCtaData.buttonHref;
const { contacts } = contactCtaData;
return (
<section className={styles.section} aria-labelledby="contact-cta-heading">
@@ -42,19 +58,19 @@ export default function ContactCtaSection() {
<div className={styles.top}>
<div className={styles.text}>
<h2 id="contact-cta-heading" className={styles.heading}>
{heading}
{resolvedHeading}
</h2>
<p className={styles.lead}>{lead}</p>
<p className={styles.lead}>{resolvedLead}</p>
</div>
<Link href={buttonHref} className="btn btn--filled-inverse">
</div>
<div>
<Link href={resolvedButtonHref} className="btn btn--filled-inverse">
<span className="btn-icon animate" aria-hidden="true">
<ArrowRightIcon size={16} weight="bold" />
</span>
{buttonLabel}
{resolvedButtonLabel}
</Link>
</div>
<hr className={styles.divider} aria-hidden="true" />
<div className={styles.contactRow}>
+5 -3
View File
@@ -17,10 +17,11 @@
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.heading {
max-width: 11ch;
max-width: 24ch;
text-align: left;
color: var(--color-accent);
}
@@ -29,6 +30,7 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.item {
@@ -93,7 +95,7 @@
.answer {
max-width: 52ch;
color: var(--color-text-muted);
font-size: var(----font-size-xs);
font-size: var(--font-size-xs);
line-height: 1.5;
}
@@ -103,7 +105,7 @@
}
.inner {
grid-template-columns: 280px 1fr;
grid-template-columns: minmax(0, 40%) minmax(0, 60%);
gap: var(--space-16);
}
+136
View File
@@ -15,3 +15,139 @@ export const homepageFaq = [
"Wir planen flexibel und reinigen dann, wenn es Ihren Betrieb am wenigsten stört. Auf Wunsch auch früh morgens, abends oder am Wochenende.",
},
];
export const leistungenFaq = [
{
slug: "unterhaltsreinigung",
title: "Häufige Fragen zur Unterhaltsreinigung",
faq: [
{
question: "Wie oft findet die Unterhaltsreinigung statt?",
answer:
"Das legen wir gemeinsam mit Ihnen fest – ob täglich, wöchentlich oder in individuellen Intervallen. Entscheidend sind Nutzung, Besucherfrequenz und Ihre Hygieneanforderungen.",
},
{
question:
"Welche Bereiche werden bei der Unterhaltsreinigung gereinigt?",
answer:
"Wir reinigen Büros, Sanitärbereiche, Gemeinschaftsflächen und weitere vereinbarte Räume nach einem festen Reinigungsplan, der auf Ihr Objekt abgestimmt ist.",
},
{
question: "Kann ich die Reinigungszeiten selbst bestimmen?",
answer:
"Ja, wir richten uns nach Ihrem Betriebsablauf und reinigen dann, wenn es am wenigsten stört – auch früh morgens, abends oder am Wochenende.",
},
{
question:
"Wie wird die Qualität der Unterhaltsreinigung sichergestellt?",
answer:
"Durch regelmäßige Objektkontrollen, feste Ansprechpartner und ein dokumentiertes Qualitätsmanagement stellen wir dauerhaft gleichbleibende Ergebnisse sicher.",
},
],
},
{
slug: "fensterreinigung",
title: "Häufige Fragen zur Fensterreinigung",
faq: [
{
question: "Wie oft sollten Fenster professionell gereinigt werden?",
answer:
"Das hängt von Lage, Ausrichtung und Nutzung Ihres Gebäudes ab. Wir empfehlen ein individuelles Intervall, das wir gemeinsam mit Ihnen festlegen.",
},
{
question: "Reinigen Sie auch schwer zugängliche Glasflächen?",
answer:
"Ja, mit geschultem Personal und passendem Equipment reinigen wir auch höhere und schwer erreichbare Fensterfronten sicher und zuverlässig.",
},
{
question: "Werden Rahmen und Fensterbänke mitgereinigt?",
answer:
"Auf Wunsch beziehen wir Rahmen, Fensterbänke und angrenzende Glasflächen wie Eingangsbereiche in die Reinigung mit ein.",
},
{
question: "Was kostet eine professionelle Fensterreinigung?",
answer:
"Die Kosten richten sich nach Fensteranzahl, Erreichbarkeit und Reinigungsintervall. Nach einer kurzen Besichtigung erhalten Sie ein transparentes Angebot ohne versteckte Nebenkosten.",
},
],
},
{
slug: "grundreinigung",
title: "Häufige Fragen zur Grundreinigung",
faq: [
{
question: "Wann ist eine Grundreinigung sinnvoll?",
answer:
"Zum Beispiel nach einem Umzug, vor einer Neueröffnung, nach Umbauarbeiten oder als saisonale Tiefenreinigung bei besonderem Pflegebedarf.",
},
{
question:
"Wie unterscheidet sich die Grundreinigung von der Unterhaltsreinigung?",
answer:
"Die Grundreinigung ist eine intensive, meist einmalige Tiefenreinigung mit professionellem Equipment, während die Unterhaltsreinigung regelmäßig und planbar erfolgt.",
},
{
question: "Welche Materialien können gereinigt werden?",
answer:
"Wir behandeln unterschiedliche Böden, Oberflächen und Nebenräume fachgerecht und materialschonend, abgestimmt auf den jeweiligen Belag.",
},
{
question: "Wie schnell kann eine Grundreinigung durchgeführt werden?",
answer:
"Je nach Objektgröße und Verschmutzungsgrad planen wir kurzfristige Termine, etwa nach Umzug oder vor einer Eröffnung, um Ihren Zeitrahmen einzuhalten.",
},
],
},
{
slug: "hausmeisterdienste",
title: "Häufige Fragen zu den Hausmeisterdiensten",
faq: [
{
question: "Welche Aufgaben übernehmen Ihre Hausmeisterdienste?",
answer:
"Von Kontrollgängen über Kleinreparaturen bis zu Grünpflege und Winterdienst – wir kümmern uns zuverlässig um die anfallenden Aufgaben rund um Ihr Objekt.",
},
{
question: "Gibt es einen festen Ansprechpartner?",
answer:
"Ja, Sie erhalten einen festen Ansprechpartner, der die Abläufe in Ihrem Objekt kennt und für reibungslose Koordination sorgt.",
},
{
question: "Übernehmen Sie auch den Winterdienst?",
answer:
"Ja, der Winterdienst gehört zu unseren Hausmeisterdiensten und wird je nach Witterung und Vereinbarung zuverlässig durchgeführt.",
},
{
question: "Wie flexibel sind die Einsatzzeiten?",
answer:
"Die Einsatzzeiten richten sich nach Ihrem Betriebsablauf und dem Publikumsverkehr, damit Kontrollgänge und Arbeiten möglichst wenig stören.",
},
],
},
{
slug: "treppenhausreinigung",
title: "Häufige Fragen zur Treppenhausreinigung",
faq: [
{
question: "Wie oft wird das Treppenhaus gereinigt?",
answer:
"Die Häufigkeit richten wir nach Bewohnerzahl und Besucherfrequenz aus – von täglich bis zu individuell vereinbarten Intervallen.",
},
{
question: "Werden auch Handläufe und Türen mitgereinigt?",
answer:
"Ja, neben Böden reinigen wir auch Handläufe, Türen und Eingangsflächen, um ein durchgängig gepflegtes Erscheinungsbild sicherzustellen.",
},
{
question: "Wie wird die Störung für Bewohner minimiert?",
answer:
"Die Reinigung erfolgt in festen, abgestimmten Intervallen zu Zeiten, die den Alltag von Bewohnern und Mitarbeitenden möglichst wenig beeinträchtigen.",
},
{
question: "Gibt es feste Ansprechpartner für die Treppenhausreinigung?",
answer:
"Ja, Sie haben durchgehend denselben Ansprechpartner, sodass Absprachen unkompliziert und Zuständigkeiten klar bleiben.",
},
],
},
];
@@ -0,0 +1,89 @@
.maintenanceCleaning {
background: var(--color-background-linear);
padding-block: var(--space-16);
}
.inner {
max-width: var(--container-width);
margin-inline: auto;
padding-inline: var(--space-4);
}
.intro {
margin-bottom: var(--space-6);
}
.heading {
max-width: 24ch;
padding: 0 0 8px 0;
}
.lead {
font-size: var(--font-size-base);
max-width: 48ch;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-5);
}
.card {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-4);
background: var(--color-surface-muted, #e6ede9);
border-radius: 12px;
padding: var(--space-5);
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
color: var(--color-accent);
}
.cardText {
color: var(--font-heading-color);
font-size: var(--font-size-xs);
}
/* Tablet */
@media (min-width: 768px) {
.maintenanceCleaning {
padding-block: var(--space-24);
}
.heading {
text-align: left;
}
.lead {
text-align: left;
}
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Desktop: Grid dreispaltig, Intro bleibt volle Breite oben */
@media (min-width: 1024px) {
.maintenanceCleaning {
padding-block: var(--space-24);
}
.inner {
padding-inline: var(--space-6);
}
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
+106
View File
@@ -0,0 +1,106 @@
.process {
padding-block: var(--space-16);
}
.inner {
max-width: var(--container-width);
margin-inline: auto;
padding-inline: var(--space-4);
}
.intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.heading {
max-width: 24ch;
}
.lead {
max-width: 40ch;
}
.steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-width: 0;
}
.step {
display: flex;
align-items: center;
gap: var(--space-4);
min-width: 0;
}
.stepNumber {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-accent);
color: var(--color-surface);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
box-shadow: 0 0 0 8px var(--color-surface-muted, #e6ede9);
}
.stepTitle {
color: var(--font-heading-color);
font-size: var(--font-size-h5);
font-weight: var(--font-weight-bold);
min-width: 0;
padding-left: 16px;
}
/* Tablet */
@media (min-width: 768px) {
.process {
padding-block: var(--space-24);
}
.stepNumber {
width: 48px;
height: 48px;
}
.steps {
gap: var(--space-10);
}
}
/* Desktop: 60/40 Grid, Intro links, Steps rechts, beide vertikal zentriert */
@media (min-width: 1024px) {
.process {
padding-block: var(--space-24);
}
.steps {
gap: var(--space-16);
}
.stepNumber {
width: 64px;
height: 64px;
box-shadow: 0 0 0 12px var(--color-surface-muted, #e6ede9);
}
.inner {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 4fr);
gap: var(--space-16);
align-items: center;
padding-inline: var(--space-6);
}
.intro {
margin-bottom: 0;
}
}
+103
View File
@@ -0,0 +1,103 @@
.qualityClaim {
padding-block: var(--space-16);
}
.inner {
max-width: var(--container-width);
margin-inline: auto;
padding-inline: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.imageWrapper {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 24px;
overflow: hidden;
display: none;
}
.image {
object-fit: cover;
}
.content {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.heading {
position: relative;
padding-left: var(--space-4);
max-width: 20ch;
}
.heading::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background: var(--color-accent);
}
.textGrid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-2);
max-width: 48ch;
}
.text {
color: var(--color-text-muted);
}
.actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
/* Tablet */
@media (min-width: 768px) {
.qualityClaim {
padding-block: var(--space-24);
}
.imageWrapper {
aspect-ratio: 16 / 9;
display: block;
max-height: 320px;
max-width: 320px;
}
.textGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 100%;
gap: var(--space-8);
}
}
/* Desktop: Bild feste Breite links, Content rechts */
@media (min-width: 1024px) {
.inner {
flex-direction: row;
align-items: center;
gap: var(--space-10);
}
.imageWrapper {
width: 320px;
max-height: 100%;
flex-shrink: 0;
aspect-ratio: 2 / 3;
}
.content {
flex: 1;
}
}
+197
View File
@@ -0,0 +1,197 @@
/* ==========================================================
Mobile First Basis-Styles (< 768px)
========================================================== */
.slider {
/* Slide-Abstand zentral als Variable, pro Breakpoint überschreibbar */
--slide-spacing: 1rem;
width: 100%;
margin: 0 auto;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.viewport {
overflow: hidden;
border-radius: 24px;
}
.container {
display: flex;
touch-action: pan-y pinch-zoom;
/* Kompensiert das padding-left des ersten Slides, damit dieser
bündig am linken Rand des Viewports beginnt. */
margin-left: calc(var(--slide-spacing) * -1);
}
.slide {
flex: 0 0 100%;
min-width: 0;
/* Erzeugt den sichtbaren Abstand zum vorherigen Slide.
Padding statt margin, weil Embla SSR nur bei padding-basiertem
Spacing zuverlässig unterstützt. */
padding-left: var(--slide-spacing);
}
/* Mobil: gestapelt, Bild oben, Text darunter */
.split {
display: grid;
grid-template-columns: 1fr;
height: auto;
overflow: hidden;
}
.imagePanel {
position: relative;
min-width: 0;
height: 230px;
order: -1;
overflow: hidden;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
}
.image {
object-fit: cover;
object-position: center;
}
.textPanel {
display: flex;
flex-direction: column;
justify-content: start;
min-width: 0;
min-height: 220px;
padding: 1.5rem;
color: #fff;
background:
radial-gradient(circle at 5% 0%, rgb(117 211 193 / 58%), transparent 49%),
linear-gradient(135deg, #42ac99 0%, var(--color-accent) 80%);
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
}
.heading {
color: #f9f9f9;
padding: 0 0 8px 0;
}
.text {
margin: 0;
line-height: var(--line-height-card);
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.arrow {
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 50%;
background: #000;
color: #fff;
cursor: pointer;
}
.arrow:hover:not(:disabled) {
background: #242424;
}
.arrow:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.dots {
display: flex;
align-items: center;
gap: 0.7rem;
}
.dot {
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 50%;
background: #b8b8b8;
cursor: pointer;
}
.dotActive {
background: #000;
}
.arrow:focus-visible,
.dot:focus-visible {
outline: 3px solid #177f73;
outline-offset: 3px;
}
/* ==========================================================
Tablet (>= 768px): zurück zum Split-Layout, Text links / Bild rechts
========================================================== */
@media (min-width: 768px) {
.slider {
--slide-spacing: 1.5rem;
}
.split {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
height: 360px;
}
.imagePanel {
height: 360px;
order: 0;
border-top-right-radius: 24px;
border-bottom-right-radius: 24px;
border-top-left-radius: 0;
}
.textPanel {
min-height: 0;
padding: clamp(1.5rem, 3vw, 3rem);
border-top-left-radius: 24px;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 0;
}
}
/* ==========================================================
Desktop (>= 1024px): finale Maße wie im Ursprungsdesign
========================================================== */
@media (min-width: 1024px) {
.slider {
width: 992px;
--slide-spacing: 2rem;
}
.viewport {
border-radius: 24px;
}
.split {
height: 400px;
}
.imagePanel {
height: 400px;
}
.textPanel {
padding: clamp(2rem, 4vw, 4.75rem);
}
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import useEmblaCarousel from "embla-carousel-react";
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react/dist/ssr";
import styles from "./Slider.module.css";
type Slide = {
heading: string;
text: string;
image: string;
};
type SliderProps = {
slides: Slide[];
imageAlt: string;
};
export function Slider({ slides, imageAlt }: SliderProps) {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start",
loop: false,
duration: 28,
});
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
const scrollPrevious = useCallback(() => {
emblaApi?.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
emblaApi?.scrollNext();
}, [emblaApi]);
const scrollTo = useCallback(
(index: number) => {
emblaApi?.scrollTo(index);
},
[emblaApi],
);
const updateSelectedSlide = useCallback(() => {
if (!emblaApi) {
return;
}
setSelectedIndex(emblaApi.selectedScrollSnap());
setCanScrollPrev(emblaApi.canScrollPrev());
setCanScrollNext(emblaApi.canScrollNext());
}, [emblaApi]);
const initializeNavigation = useCallback(() => {
if (!emblaApi) {
return;
}
setScrollSnaps(emblaApi.scrollSnapList());
updateSelectedSlide();
}, [emblaApi, updateSelectedSlide]);
useEffect(() => {
if (!emblaApi) {
return;
}
initializeNavigation();
emblaApi.on("reInit", initializeNavigation);
emblaApi.on("select", updateSelectedSlide);
return () => {
emblaApi.off("reInit", initializeNavigation);
emblaApi.off("select", updateSelectedSlide);
};
}, [emblaApi, initializeNavigation, updateSelectedSlide]);
if (slides.length === 0) {
return null;
}
return (
<section
className={styles.slider}
aria-roledescription="carousel"
aria-label="Weitere Informationen"
>
<div className={styles.viewport} ref={emblaRef}>
<div className={styles.container}>
{slides.map((slide, index) => (
<article
key={`${slide.heading}-${index}`}
className={styles.slide}
aria-hidden={selectedIndex !== index}
>
<div className={styles.split}>
<div className={styles.textPanel}>
<h3 className={styles.heading}>{slide.heading}</h3>
<p className={styles.text}>{slide.text}</p>
</div>
<div className={styles.imagePanel}>
<Image
src={slide.image}
alt={imageAlt}
fill
sizes="(max-width: 700px) 100vw, 50vw"
className={styles.image}
/>
</div>
</div>
</article>
))}
</div>
</div>
{slides.length > 1 && (
<div className={styles.controls}>
<button
type="button"
className={styles.arrow}
onClick={scrollPrevious}
disabled={!canScrollPrev}
suppressHydrationWarning
aria-label="Vorheriger Slide"
>
<CaretLeftIcon size={20} weight="bold" aria-hidden="true" />
</button>
<div className={styles.dots} aria-label="Slide auswählen">
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
className={`${styles.dot} ${
selectedIndex === index ? styles.dotActive : ""
}`}
onClick={() => scrollTo(index)}
aria-label={`Slide ${index + 1} von ${slides.length}`}
aria-current={selectedIndex === index ? "true" : undefined}
/>
))}
</div>
<button
type="button"
className={styles.arrow}
onClick={scrollNext}
disabled={!canScrollNext}
suppressHydrationWarning
aria-label="Nächster Slide"
>
<CaretRightIcon size={20} weight="bold" aria-hidden="true" />
</button>
</div>
)}
</section>
);
}