update
This commit is contained in:
1 parent
574e07603d
commit
cc2b84dcad
27 files changed
+2214
-360
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user