Produktfinder in wenigen Fragen
Vier Fragen mit Fortschritt und Zurück, danach ein Produkt mit Begründung aus den eigenen Antworten, zwei Alternativen, Neustart und teilbarem Link.
Beauty, D2C, Kurswahl: wenn jede Frage eine kurze Erklärung braucht und das Ergebnis ein eigener Moment sein soll.
Ein Finder führt in drei bis fünf Fragen zu einem passenden Produkt. Jede Frage steht für sich, ein Fortschritt zeigt, wie weit es noch ist, „Zurück“ behält die Antworten. Das Ergebnis nennt ein Produkt und begründet es mit den eigenen Antworten („weil du … gewählt hast“). Darunter stehen zwei Alternativen und ein Neustart. Die Antworten stehen in der Adresse, so lässt sich das Ergebnis teilen und wieder öffnen.
Wer vor zwanzig Seren steht, will nicht vergleichen, sondern beraten werden. Die Begründung macht die Empfehlung nachvollziehbar und damit glaubwürdig. Alternativen nehmen den Druck, dass es nur eine richtige Antwort gibt.
Wann einsetzen
Hautpflege und Kosmetik, Shops mit erklärungsbedürftigen Produkten, Kurs- und Tarifwahl, Fahrschulklassen, Paket-Empfehlung bei Dienstleistern.
Wann nicht
Bei weniger als fünf Produkten (dann eine Vergleichstabelle). Wenn die Fragen Gesundheitsdaten berühren, ohne dass eine Fachperson dahintersteht. Nicht als Hürde vor dem Sortiment: die Produktliste bleibt immer direkt erreichbar.
Varianten
- Schritt für Schritt: eine Frage je Ansicht mit Fortschritt
- Alles auf einer Seite: Antworten als Segmente, Ergebnis aktualisiert sich live
- Ergebnis mit Routine aus mehreren Produkten statt einem
- Ergebnis per Link teilen oder per E-Mail senden
Umsetzung
Ein <form> mit einem <fieldset> je Frage, die Frage als <h2> in der <legend>, Antworten als Radio-Knöpfe in großen Feldern. „Weiter“ erst nach Auswahl aktiv, kein automatisches Weiterspringen (WCAG 3.2.2). Nach dem Wechsel Fokus auf die neue Überschrift (tabindex="-1"). Zurück behält die Antworten. Auswertung über eine Punktetabelle; die Begründung baut sich aus den Antworten („Weil du „Rötungen“ gewählt hast, …“), nicht aus Werbetext. Antworten als Kurzcode in der URL, so lässt sich das Ergebnis teilen und neu laden. Teilen per navigator.share oder Zwischenablage, Rückmeldung in role="status", Ausweichweg mit markiertem Link. Variante Live: alle Fragen als Chips, Ergebnis und Bild wechseln sofort.
Robust umsetzen
Telefon. Eine Frage füllt den Bildschirm, Antworten als volle Breite mit großen Zielen, „Weiter“ unten fest im Daumenbereich. Ergebnis untereinander: Produkt, Begründung, Alternativen.
Ohne Bewegung. Fragen wechseln ohne Versatz und ohne Überblendung, Fortschritt springt.
Barrierefrei. fieldset und legend je Frage, native Radio-Knöpfe mit sichtbarem Fokus. Kein Weiterspringen bei Auswahl (WCAG 3.2.2). Fokus auf die neue Frage, Fortschritt als Text. Ergebnis in einer Überschrift, Begründung als Liste. Teilen-Rückmeldung über role="status".
Code
<!DOCTYPE html>
<!--
Produktfinder in wenigen Fragen
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/produktfinder-quiz/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Produktfinder in wenigen Fragen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Rezept Kollektion und Ritual: warmes Off-White, Fast-Schwarz mit Rotstich, Farbe kommt vom Produkt */
--grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #6b645b; --deko: #8c857b; --signal: #a3402a;
--ui: cubic-bezier(0.25, 1, 0.5, 1); /* Toteme --Carousel_Ease */
--t-ui: 250ms; --t-bild: 1000ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; }
.mono { font: 400 11px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; }
.ziffern { font-variant-numeric: tabular-nums; }
.breit { font-stretch: 125%; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Shop-Kopf */
.shopkopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; height: 58px; padding: 0 clamp(16px, 3vw, 36px); border-bottom: 1px solid var(--linie); }
.shopkopf nav { display: flex; gap: 22px; font-size: 13.5px; }
.shopkopf nav [aria-current] { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 1px; }
.wortmarke { font-size: 19px; font-weight: 450; font-stretch: 125%; letter-spacing: .26em; margin-right: -.26em; }
.shopkopf .rechts { justify-self: end; display: flex; gap: 20px; font-size: 13.5px; }
/* Bühne */
.finder { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); min-height: calc(100svh - 58px); }
.bild { position: relative; background: var(--flaeche); overflow: hidden; display: grid; }
.bild::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse 70% 55% at 62% 38%, rgba(255,255,255,.55), rgba(255,255,255,0) 70%); pointer-events: none; }
.bild .ebene { position: relative; z-index: 1; grid-area: 1 / 1; display: grid; place-items: center; padding: 48px 32px 96px; opacity: 0; transition: opacity var(--t-bild) var(--ui); }
.bild .ebene.an { opacity: 1; }
.bild svg.motiv { width: min(100%, 360px); height: auto; overflow: visible; }
.bild svg.motiv.gruppe { width: min(100%, 600px); }
.bild .unterschrift span { white-space: nowrap; }
.bild .unterschrift { flex-wrap: wrap; position: absolute; left: clamp(20px, 3vw, 36px); right: clamp(20px, 3vw, 36px); bottom: 26px; display: flex; justify-content: space-between; gap: 16px; color: var(--meta); z-index: 1; }
.bild .beispiel { border: 1px dashed var(--deko); padding: 4px 7px 3px; border-radius: 2px; }
.inhalt { padding: clamp(28px, 4.5vw, 64px) clamp(20px, 5vw, 80px) 40px; display: flex; flex-direction: column; min-width: 0; }
.einleitung .mono { color: var(--meta); }
.einleitung h1 { font-size: clamp(30px, 3.4vw, 50px); line-height: 1.04; font-weight: 420; font-stretch: 125%; letter-spacing: -.01em; margin: 12px 0 0; max-width: 18ch; text-wrap: balance; }
.einleitung p { font-size: 15.5px; line-height: 1.5; color: var(--meta); max-width: 46ch; margin-top: 12px; text-wrap: pretty; }
/* Fortschritt */
.fortschritt { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px; margin: 34px 0 26px; }
.fortschritt .balken { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.fortschritt .balken i { height: 2px; background: var(--linie); position: relative; overflow: hidden; }
.fortschritt .balken i::after { content: ''; position: absolute; inset: 0; background: var(--text); transform-origin: left; transform: scaleX(0); transition: transform var(--t-ui) var(--ui); }
.fortschritt .balken i.voll::after { transform: scaleX(1); }
/* Frage */
.frage { border: 0; min-width: 0; view-transition-name: frage; }
.frage legend { padding: 0; width: 100%; }
.frage h2 { font-size: clamp(22px, 2.1vw, 30px); line-height: 1.15; font-weight: 420; letter-spacing: -.005em; max-width: 26ch; outline: none; text-wrap: balance; }
.frage .hilfe { font-size: 14px; color: var(--meta); margin: 8px 0 22px; line-height: 1.45; }
.optionen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.option { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 16px 16px 15px; background: rgba(255,255,255,.45); border: 1px solid var(--linie); cursor: pointer; transition: border-color var(--t-ui) var(--ui), background-color var(--t-ui) var(--ui); }
.option input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.option .kreis { grid-row: span 2; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--deko); margin-top: 1px; display: grid; place-items: center; transition: border-color var(--t-ui) var(--ui); }
.option .kreis::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--text); transform: scale(0); transition: transform var(--t-ui) var(--ui); }
.option b { font-weight: 500; font-size: 15.5px; line-height: 1.3; }
.option small { font-size: 13px; color: var(--meta); line-height: 1.4; }
.option:hover { border-color: var(--deko); background: rgba(255,255,255,.7); }
.option:has(input:checked) { border-color: var(--text); background: #fff; }
.option:has(input:checked) .kreis { border-color: var(--text); }
.option:has(input:checked) .kreis::after { transform: scale(1); }
.option:has(input:focus-visible) { outline: 1.5px solid var(--text); outline-offset: 3px; }
.steuerung { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 26px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 10px; height: 48px; padding: 0 22px; border: 1px solid var(--text); background: var(--text); color: var(--grund); font-size: 14px; font-weight: 500; letter-spacing: .01em; cursor: pointer; text-decoration: none; transition: background-color var(--t-ui) var(--ui), color var(--t-ui) var(--ui), opacity var(--t-ui) var(--ui); }
.knopf:hover { background: #2a2524; }
.knopf:disabled { opacity: .35; cursor: not-allowed; }
.knopf.hell { background: transparent; color: var(--text); border-color: var(--linie); }
.knopf.hell:hover { border-color: var(--text); background: transparent; }
.knopf:focus-visible, .textknopf:focus-visible { outline: 1.5px solid var(--text); outline-offset: 3px; }
.textknopf { background: none; border: 0; font-size: 14px; display: inline-flex; gap: 8px; align-items: center; cursor: pointer; padding: 12px 4px; text-decoration: underline; text-underline-offset: 4px; text-decoration-color: var(--linie); }
.textknopf:hover { text-decoration-color: var(--text); }
.textknopf[hidden] { display: none; }
.hinweis-auswahl { font-size: 13px; color: var(--meta); }
/* Ergebnis */
.ergebnis { display: grid; gap: 22px; margin-top: 30px; }
[data-variante="live"] .ergebnis { margin-top: 8px; }
.ergebnis[hidden] { display: none; }
.ergebnis h2 { font-size: clamp(28px, 2.8vw, 42px); line-height: 1.05; font-weight: 420; font-stretch: 125%; letter-spacing: -.01em; outline: none; }
.formel { font: 400 12px/1.5 'Fragment Mono', monospace; letter-spacing: .04em; text-transform: uppercase; color: var(--meta); margin-top: 8px; }
.preis { font-size: 17px; font-variant-numeric: tabular-nums; margin-top: 8px; }
.preis small { color: var(--meta); font-size: 13px; }
.gruende { list-style: none; border-top: 1px solid var(--linie); }
.gruende li { display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid var(--linie); font-size: 15px; line-height: 1.45; text-wrap: pretty; }
.gruende li .mono { color: var(--deko); padding-top: 3px; }
.gruende q { quotes: '„' '“'; font-weight: 500; }
.aktionen { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; }
.teilen-status { font-size: 13.5px; color: var(--meta); min-height: 1.4em; width: 100%; }
.teilen-status input { width: 100%; margin-top: 8px; font: 400 12px/1 'Fragment Mono', monospace; padding: 10px; border: 1px solid var(--linie); background: #fff; color: var(--text); }
.alternativen h3 { margin-bottom: 10px; color: var(--meta); }
.alternativen ul { list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.alt { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 12px; align-items: center; padding: 10px 12px 10px 10px; background: var(--flaeche); }
.alt svg { width: 64px; height: 76px; }
.alt b { font-weight: 500; font-size: 14.5px; display: block; }
.alt span { font-size: 12.5px; color: var(--meta); line-height: 1.35; display: block; margin-top: 2px; }
.alt .ziffern { color: var(--text); margin-top: 4px; }
.werte-zeile { margin-top: auto; padding-top: 28px; color: var(--meta); font-size: 10px; }
/* Fragenwechsel: View Transition (16 px, 250 ms), Richtung je nach Weiter/Zurück */
::view-transition-group(frage) { animation-duration: 250ms; animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); }
::view-transition-old(frage) { animation: 150ms cubic-bezier(0.25, 1, 0.5, 1) both raus; }
::view-transition-new(frage) { animation: 250ms cubic-bezier(0.25, 1, 0.5, 1) both rein; }
@keyframes raus { to { opacity: 0; translate: calc(var(--richtung, 1) * -16px) 0; } }
@keyframes rein { from { opacity: 0; translate: calc(var(--richtung, 1) * 16px) 0; } }
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
.reduziert::view-transition-old(frage), .reduziert::view-transition-new(frage) { animation: none; }
.reduziert .bild .ebene, .reduziert .fortschritt .balken i::after, .reduziert .option, .reduziert .option .kreis::after { transition: none; }
/* Variante „Live“: alle Fragen auf einer Seite, Ergebnis aktualisiert sich */
[data-variante="live"] .fortschritt, [data-variante="live"] .steuerung { display: none; }
[data-variante="live"] .frage { margin-bottom: 22px; view-transition-name: none; }
[data-variante="live"] .frage h2 { font-size: 17px; font-weight: 500; letter-spacing: 0; }
[data-variante="live"] .frage .hilfe { display: none; }
[data-variante="live"] .optionen { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
[data-variante="live"] .option { grid-template-columns: auto; padding: 9px 14px; border-radius: 999px; }
[data-variante="live"] .option .kreis, [data-variante="live"] .option small { display: none; }
[data-variante="live"] .option b { font-size: 14px; font-weight: 450; }
[data-variante="live"] .option:has(input:checked) { background: var(--text); color: var(--grund); }
[data-variante="live"] .einleitung h1 { font-size: clamp(28px, 2.6vw, 38px); }
[data-variante="live"] .fragen-live { margin-top: 26px; }
.offen-hinweis { font-size: 15px; color: var(--meta); padding: 18px 0; border-top: 1px solid var(--linie); }
.schild { position: absolute; z-index: 2; left: clamp(20px, 3vw, 36px); top: clamp(20px, 3vw, 36px); right: 20px; display: grid; gap: 4px; }
.schild[hidden] { display: none; }
.schild-name { font-size: clamp(26px, 2.6vw, 40px); font-weight: 420; font-stretch: 125%; letter-spacing: -.01em; line-height: 1.05; margin-top: 6px; }
.schild .formel { margin-top: 2px; }
[data-variante="live"] .frage h2 { max-width: none; }
/* Pult */
.pult { position: fixed; right: 14px; top: 72px; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; background: rgba(18, 16, 16, .88); color: var(--grund); border-radius: 999px; }
.pult .mono { font-size: 9.5px; color: rgba(246, 243, 238, .6); }
.pult button { background: none; border: 0; color: rgba(246, 243, 238, .75); font: 400 10px/1 'Fragment Mono', monospace; letter-spacing: .06em; text-transform: uppercase; padding: 7px 10px; border-radius: 999px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: var(--grund); color: var(--text); }
.pult button:focus-visible { outline: 1.5px solid var(--grund); outline-offset: 2px; }
/* Gedachter Klick in der Vorführung */
.tipp { animation: tipp 500ms var(--ui); }
@keyframes tipp { 30% { box-shadow: 0 0 0 4px rgba(18,16,16,.12); } }
.reduziert .tipp { animation: none; }
@media (hover: none) {
.option:hover { border-color: var(--linie); background: rgba(255,255,255,.45); }
.option:has(input:checked):hover { border-color: var(--text); background: #fff; }
}
@media (max-width: 900px) {
.shopkopf nav { display: none; }
.shopkopf { grid-template-columns: 1fr auto; }
.finder { grid-template-columns: 1fr; min-height: 0; }
.bild { min-height: 300px; max-height: 46svh; }
.bild .ebene { padding: 24px 24px 64px; }
.bild svg.motiv { width: min(100%, 300px); max-height: 38svh; }
.bild svg.motiv.gruppe { width: min(100%, 440px); }
.inhalt { padding-top: 28px; }
}
@media (max-width: 560px) {
.shopkopf .rechts span:first-child { display: none; }
.optionen { grid-template-columns: 1fr; }
.bild { min-height: 220px; max-height: 34svh; }
.bild svg.motiv { max-height: 26svh; }
.einleitung p { display: none; }
.fortschritt { margin: 22px 0 18px; }
.steuerung { position: sticky; bottom: 0; background: linear-gradient(transparent, var(--grund) 30%); padding: 18px 0 14px; margin-top: 10px; }
.steuerung .knopf { flex: 1; }
.hinweis-auswahl { display: none; }
.bild .unterschrift { bottom: 12px; }
.bild .unterschrift [data-bildtext] { display: none; }
.alternativen ul { grid-template-columns: 1fr; }
.aktionen .knopf { flex: 1 1 auto; }
.pult { top: auto; bottom: 12px; right: 12px; }
}
/* Vorschau 960 × 720 */
.vorschau body { overflow: hidden; }
.vorschau .shopkopf, .vorschau .werte-zeile, .vorschau .einleitung p { display: none; }
.vorschau .finder { min-height: 100svh; height: 100svh; grid-template-rows: 100svh; }
.vorschau .inhalt { overflow: hidden; }
.vorschau body.hat-ergebnis[data-variante="schritte"] .einleitung { display: none; }
.vorschau body.hat-ergebnis[data-variante="schritte"] .ergebnis { margin-top: 0; }
.vorschau .inhalt { padding: 34px 40px 20px; }
.vorschau .einleitung h1 { font-size: 34px; }
.vorschau .fortschritt { margin: 24px 0 20px; }
.vorschau .ergebnis { gap: 16px; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<radialGradient id="schatten"><stop offset="0" stop-color="#5a5048" stop-opacity=".35"/><stop offset="1" stop-color="#5a5048" stop-opacity="0"/></radialGradient>
<linearGradient id="glanz" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".2" stop-color="#fff" stop-opacity=".55"/><stop offset=".32" stop-color="#fff" stop-opacity="0"/><stop offset=".86" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".16"/></linearGradient>
</defs>
</svg>
<header class="shopkopf">
<nav class="mono" aria-label="Shop"><span>Pflege</span><span>Wirkstoffe</span><span aria-current="page">Finder</span></nav>
<p class="wortmarke">SAMETA</p>
<div class="rechts mono"><span>Krefeld</span><span>Korb (0)</span></div>
</header>
<main class="finder" data-finder>
<div class="bild" data-bild aria-hidden="true">
<div class="ebene an" data-ebene></div>
<div class="ebene" data-ebene></div>
<div class="schild" data-schild hidden><p class="mono" style="color:var(--meta)">Live-Empfehlung</p><p class="schild-name" data-schild-name></p><p class="formel" data-schild-formel></p></div>
<p class="unterschrift mono"><span data-bildtext>Serien · 30 und 50 ml</span><span class="beispiel">Beispielprodukte · erfunden</span></p>
</div>
<div class="inhalt">
<div class="einleitung">
<p class="mono">Hautpflege-Finder · Sameta, Krefeld</p>
<h1 data-titel>Welche Pflege passt zu deiner Haut?</h1>
<p>Vier Fragen, eine Empfehlung mit Begründung und zwei Alternativen. Deine Antworten bleiben in deinem Browser.</p>
</div>
<form data-form novalidate>
<div class="fortschritt" data-fortschritt>
<p class="mono ziffern" data-schritt aria-live="polite">Frage 1 von 4</p>
<div class="balken" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div data-fragen></div>
<div class="steuerung">
<button type="button" class="textknopf" data-zurueck hidden><span aria-hidden="true">←</span> Zurück</button>
<span class="hinweis-auswahl" data-hinweis></span>
<button type="submit" class="knopf" data-weiter disabled>Weiter <span aria-hidden="true">→</span></button>
</div>
</form>
<section class="ergebnis" data-ergebnis hidden aria-label="Deine Empfehlung"></section>
<p class="mono werte-zeile" data-werte></p>
</div>
</main>
<div class="pult">
<span class="mono">Variante</span>
<button type="button" data-v="schritte" aria-pressed="true">Schritte</button>
<button type="button" data-v="live" aria-pressed="false">Live</button>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const eur = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
// Erfundene Beispielprodukte (Sameta gibt es nicht). Formel in der Produktzeile wie im Rezept.
const PRODUKTE = {
N10: { name: 'Serum N10', formel: 'Niacinamid 10 % + Zink 1 %', menge: '30 ml', preis: 18.9, form: 'pipette', farbe: '#d9d2c3', wenn: 'wenn Glanz und Poren dich mehr stören als Rötungen.' },
C15: { name: 'Serum C15', formel: 'Vitamin C 15 % + Ferulasäure 0,5 %', menge: '30 ml', preis: 24.9, form: 'pipette', farbe: '#c98a3c', wenn: 'für einen gleichmäßigeren Teint, morgens unter Sonnenschutz.' },
A03: { name: 'Serum A03', formel: 'Retinal 0,03 % + Squalan', menge: '30 ml', preis: 32, form: 'pipette', farbe: '#7d3b2a', wenn: 'wenn deine Haut Wirkstoffe gewohnt ist; nur abends.' },
H3: { name: 'Serum H3', formel: 'Hyaluronsäure 2 % + Panthenol 1 %', menge: '30 ml', preis: 16.9, form: 'pipette', farbe: '#b9c7cc', wenn: 'als milde Feuchtigkeit für jeden Hauttyp.' },
Z5: { name: 'Serum Z5', formel: 'Azelainsäure 5 % + Allantoin', menge: '30 ml', preis: 21.9, form: 'pipette', farbe: '#c9a7a0', wenn: 'wenn Rötungen und Unreinheiten zusammenkommen.' },
C4: { name: 'Creme C4', formel: 'Ceramide 4 % + Cholesterin 1 %', menge: '50 ml', preis: 22.9, form: 'tiegel', farbe: '#e4ddd0', wenn: 'als reichhaltiger Abschluss für eine gestresste Hautbarriere.' },
B2: { name: 'Gel-Creme B2', formel: 'Beta-Glucan 2 % + Grüntee-Extrakt', menge: '50 ml', preis: 19.9, form: 'tube', farbe: '#a9b59b', wenn: 'als leichte Feuchtigkeit, die nicht glänzt.' },
M8: { name: 'Peeling M8', formel: 'Mandelsäure 8 %', menge: '30 ml', preis: 20.9, form: 'pipette', farbe: '#e0c9a6', wenn: 'zwei- bis dreimal pro Woche abends, für glattere Haut.' }
};
const REIHE = Object.keys(PRODUKTE);
// Fragen: jede Antwort verteilt Punkte und trägt einen Begründungssatz
const FRAGEN = [
{ titel: 'Wie fühlt sich deine Haut am Nachmittag an?', hilfe: 'Ohne Make-up, ein paar Stunden nach dem Waschen.', antworten: [
{ kurz: 'Spannt, eher trocken', info: 'Wangen und Stirn fühlen sich rau an', punkte: { H3: 3, C4: 3, A03: 1 }, grund: 'braucht deine Haut vor allem Feuchtigkeit und eine geschützte Hautbarriere.' },
{ kurz: 'Glänzt in der T-Zone', info: 'Stirn, Nase und Kinn, Wangen normal', punkte: { N10: 2, B2: 2, M8: 1, H3: 1 }, grund: 'darf die Textur leicht sein; die Wangen bekommen trotzdem genug.' },
{ kurz: 'Glänzt fast überall', info: 'Puder hält nicht bis zum Abend', punkte: { N10: 3, B2: 3, M8: 2, Z5: 1 }, grund: 'setzen wir auf leichte Texturen, die schnell einziehen.' },
{ kurz: 'Unauffällig', info: 'Weder Glanz noch Spannen', punkte: { C15: 1, A03: 1, H3: 1, B2: 1 }, grund: 'kannst du dich ganz auf dein Anliegen konzentrieren.' }
] },
{ titel: 'Was möchtest du als Erstes angehen?', hilfe: 'Nur eins. Die Routine wächst später mit.', antworten: [
{ kurz: 'Unreinheiten und Poren', info: 'Pickel, Mitesser, sichtbare Poren', punkte: { N10: 4, Z5: 3, M8: 3 }, grund: 'steht ein Wirkstoff gegen Unreinheiten im Mittelpunkt.' },
{ kurz: 'Rötungen', info: 'Wangen, Nase, nach dem Duschen', punkte: { Z5: 4, C4: 3, H3: 1 }, grund: 'beginnen wir mit beruhigenden Wirkstoffen.' },
{ kurz: 'Flecken und fahler Teint', info: 'Pigmentflecken, Spuren alter Pickel', punkte: { C15: 4, M8: 2, N10: 1, Z5: 1 }, grund: 'geht es um einen gleichmäßigeren Teint.' },
{ kurz: 'Feine Linien', info: 'Augen, Stirn, Mundpartie', punkte: { A03: 4, C15: 2, H3: 1 }, grund: 'darf ein Wirkstoff für glattere Haut dabei sein.' }
] },
{ titel: 'Wie reagiert deine Haut auf neue Produkte?', hilfe: 'Denk an die letzten zwei, drei Produkte, die du neu probiert hast.', antworten: [
{ kurz: 'Selten', info: 'Sie verträgt fast alles', punkte: { A03: 1, C15: 1, M8: 1 }, grund: 'sind auch höhere Konzentrationen möglich.' },
{ kurz: 'Manchmal kribbelt es', info: 'Kurz, dann ist es wieder gut', punkte: { C4: 1, Z5: 1, H3: 1, A03: -1, M8: -1 }, grund: 'bleiben wir bei mittleren Konzentrationen.', immer: true },
{ kurz: 'Oft: Brennen oder Röte', info: 'Ich muss vorsichtig sein', punkte: { C4: 3, Z5: 2, H3: 2, A03: -6, M8: -6, C15: -4 }, grund: 'lassen wir Retinal, Vitamin C und Säuren weg.', immer: true }
] },
{ titel: 'Wie viel Erfahrung hast du mit Wirkstoffen?', hilfe: 'Seren, Säuren, Retinal und Ähnliches.', antworten: [
{ kurz: 'Ich fange gerade an', info: 'Bisher Reinigung und Creme', punkte: { H3: 1, N10: 1, C4: 1, A03: -3, M8: -2 }, grund: 'starten wir mild und mit einem einzigen neuen Produkt.', immer: true },
{ kurz: 'Ein Serum nutze ich schon', info: 'Und es klappt gut', punkte: {}, grund: 'passt ein zweites Serum in deine Routine.', immer: true },
{ kurz: 'Ich kenne mich aus', info: 'Säuren und Retinal sind nichts Neues', punkte: { A03: 2, C15: 1, M8: 1 }, grund: 'darf es ein stärkerer Wirkstoff sein.' }
] }
];
const zustand = { variante: 'schritte', schritt: 0, antworten: [null, null, null, null], fertig: false };
const form = $('[data-form]'), fragenEl = $('[data-fragen]'), ergebnis = $('[data-ergebnis]');
const weiter = $('[data-weiter]'), zurueck = $('[data-zurueck]'), schrittEl = $('[data-schritt]'), hinweis = $('[data-hinweis]');
const ebenen = [...document.querySelectorAll('[data-ebene]')];
let ebeneAn = 0, bildSchluessel = '';
// ---------- Bilder: Flaschen als SVG ----------
function flasche(id, gross = true) {
const p = PRODUKTE[id], f = p.farbe;
const etikett = `<rect x="66" y="150" width="68" height="64" fill="#f6f3ee"/><text x="100" y="166" text-anchor="middle" font-family="Zalando Sans, sans-serif" font-stretch="125%" font-size="7" letter-spacing="2.2" fill="#121010">SAMETA</text><text x="100" y="194" text-anchor="middle" font-family="Zalando Sans, sans-serif" font-stretch="125%" font-weight="450" font-size="20" fill="#121010">${id}</text><text x="100" y="206" text-anchor="middle" font-family="Fragment Mono, monospace" font-size="4.6" fill="#6f685f">${p.formel.split(' + ')[0].toUpperCase()}</text>`;
let k = '';
if (p.form === 'pipette') {
k = `<ellipse cx="100" cy="246" rx="62" ry="9" fill="url(#schatten)"/>
<rect x="88" y="26" width="24" height="52" rx="12" fill="#2a2523"/><rect x="92" y="30" width="5" height="40" rx="2.5" fill="#fff" opacity=".14"/>
<rect x="80" y="74" width="40" height="20" rx="2" fill="#1d1918"/><rect x="80" y="74" width="40" height="20" rx="2" fill="url(#glanz)" opacity=".6"/>
<path d="M86 94h28v10c0 4 26 6 26 22v104c0 8-6 14-14 14H74c-8 0-14-6-14-14V126c0-16 26-18 26-22z" fill="${f}"/>
<path d="M86 94h28v10c0 4 26 6 26 22v104c0 8-6 14-14 14H74c-8 0-14-6-14-14V126c0-16 26-18 26-22z" fill="url(#glanz)"/>
${etikett}`;
} else if (p.form === 'tiegel') {
k = `<ellipse cx="100" cy="246" rx="78" ry="10" fill="url(#schatten)"/>
<rect x="34" y="112" width="132" height="36" rx="6" fill="#1d1918"/><rect x="34" y="112" width="132" height="36" rx="6" fill="url(#glanz)" opacity=".5"/>
<rect x="40" y="146" width="120" height="96" rx="14" fill="${f}"/><rect x="40" y="146" width="120" height="96" rx="14" fill="url(#glanz)"/>
<g transform="translate(0 10)">${etikett}</g>`;
} else {
k = `<ellipse cx="100" cy="248" rx="54" ry="8" fill="url(#schatten)"/>
<path d="M64 44h72l-6 150H70z" fill="${f}"/><path d="M64 44h72l-6 150H70z" fill="url(#glanz)"/>
<rect x="62" y="38" width="76" height="10" fill="${f}" opacity=".8"/><path d="M62 40h76" stroke="#121010" stroke-opacity=".15" stroke-dasharray="2 2"/>
<rect x="70" y="192" width="60" height="52" rx="4" fill="#1d1918"/><rect x="70" y="192" width="60" height="52" rx="4" fill="url(#glanz)" opacity=".5"/>
<g transform="translate(0 -60)">${etikett}</g>`;
}
return `<svg class="${gross ? 'motiv' : ''}" viewBox="0 0 200 260" role="img" aria-label="${p.name}">${k}</svg>`;
}
function gruppe() {
// Ausgangsbild: drei Formen gestaffelt
const s = id => flasche(id, false).replace('<svg class="" viewBox="0 0 200 260"', '<svg viewBox="0 0 200 260"');
return `<svg class="motiv gruppe" viewBox="0 0 460 300" aria-hidden="true">
<g transform="translate(20 40) scale(.98)">${s('C4').replace(/^<svg[^>]*>|<\/svg>$/g, '')}</g>
<g transform="translate(250 30) scale(.98)">${s('B2').replace(/^<svg[^>]*>|<\/svg>$/g, '')}</g>
<g transform="translate(130 0) scale(1.12)">${s('N10').replace(/^<svg[^>]*>|<\/svg>$/g, '')}</g>
</svg>`;
}
function zeigeBild(schluessel, inhalt, text) {
if (schluessel === bildSchluessel) return;
bildSchluessel = schluessel;
ebeneAn = 1 - ebeneAn;
ebenen[ebeneAn].innerHTML = inhalt;
ebenen.forEach((e, i) => e.classList.toggle('an', i === ebeneAn));
$('[data-bildtext]').textContent = text;
}
// ---------- Fragen ----------
function frageHtml(i) {
const f = FRAGEN[i];
return `<fieldset class="frage" data-frage="${i}">
<legend><h2 tabindex="-1" id="f${i}-titel">${f.titel}</h2></legend>
<p class="hilfe" id="f${i}-hilfe">${f.hilfe}</p>
<div class="optionen">${f.antworten.map((a, j) => `<label class="option"><input type="radio" name="f${i}" value="${j}"${zustand.antworten[i] === j ? ' checked' : ''} aria-describedby="f${i}-hilfe"><span class="kreis" aria-hidden="true"></span><b>${a.kurz}</b><small>${a.info}</small></label>`).join('')}</div>
</fieldset>`;
}
function zeigeSchritt(i, richtung = 1, fokus = true) {
const tun = () => {
zustand.schritt = i;
fragenEl.innerHTML = frageHtml(i);
schrittEl.textContent = `Frage ${i + 1} von ${FRAGEN.length}`;
document.querySelectorAll('.fortschritt .balken i').forEach((b, k) => b.classList.toggle('voll', k < i || (k === i && zustand.antworten[i] !== null)));
zurueck.hidden = i === 0;
weiter.innerHTML = i === FRAGEN.length - 1 ? 'Empfehlung zeigen <span aria-hidden="true">→</span>' : 'Weiter <span aria-hidden="true">→</span>';
knopfStand();
};
html.style.setProperty('--richtung', richtung);
if (document.startViewTransition && !reduziert() && fragenEl.children.length) {
const vt = document.startViewTransition(tun);
if (fokus) vt.updateCallbackDone.then(() => fragenEl.querySelector('h2').focus({ preventScroll: true }));
} else { tun(); if (fokus) fragenEl.querySelector('h2').focus({ preventScroll: true }); }
}
function knopfStand() {
const gewaehlt = zustand.antworten[zustand.schritt] !== null;
weiter.disabled = !gewaehlt;
hinweis.textContent = gewaehlt ? '' : 'Bitte eine Antwort wählen';
document.querySelectorAll('.fortschritt .balken i').forEach((b, k) => b.classList.toggle('voll', k < zustand.schritt || (k === zustand.schritt && gewaehlt)));
}
fragenEl.addEventListener('change', e => {
const i = +e.target.name.slice(1);
zustand.antworten[i] = +e.target.value;
if (zustand.variante === 'live') { schreibeUrl(); live(); } else knopfStand();
});
form.addEventListener('submit', e => {
e.preventDefault();
if (zustand.variante === 'live' || weiter.disabled) return;
if (zustand.schritt < FRAGEN.length - 1) zeigeSchritt(zustand.schritt + 1, 1);
else zeigeErgebnis(true);
});
zurueck.addEventListener('click', () => { if (zustand.schritt > 0) zeigeSchritt(zustand.schritt - 1, -1); });
// ---------- Auswertung ----------
function auswerten(antw) {
const summe = Object.fromEntries(REIHE.map(id => [id, 0]));
antw.forEach((j, i) => { if (j === null) return; Object.entries(FRAGEN[i].antworten[j].punkte).forEach(([id, p]) => summe[id] += p); });
const rang = [...REIHE].sort((a, b) => summe[b] - summe[a] || REIHE.indexOf(a) - REIHE.indexOf(b));
const sieger = rang[0];
const gruende = antw.map((j, i) => ({ i, a: FRAGEN[i].antworten[j] }))
.filter(({ a }) => (a.punkte[sieger] || 0) > 0 || a.immer)
.sort((x, y) => (y.a.punkte[sieger] || 0) - (x.a.punkte[sieger] || 0));
return { sieger, alternativen: rang.slice(1, 3), gruende, summe };
}
function ergebnisHtml(r, mitAktionen = true) {
const p = PRODUKTE[r.sieger];
return `<div>
<p class="mono" style="color:var(--meta)">Deine Empfehlung</p>
<h2 id="erg-titel" tabindex="-1" style="margin-top:10px">${p.name}</h2>
<p class="formel">${p.formel} · ${p.menge}</p>
<p class="preis ziffern">${eur.format(p.preis)} <small>inkl. MwSt.</small></p>
</div>
<ol class="gruende" aria-label="Begründung">${r.gruende.map((g, k) => `<li><span class="mono ziffern">0${k + 1}</span><span>Weil du <q>${g.a.kurz}</q> gewählt hast, ${g.a.grund}</span></li>`).join('')}</ol>
${mitAktionen ? `<div class="aktionen">
<a class="knopf" href="#korb">In den Korb</a>
<button type="button" class="knopf hell" data-teilen>Ergebnis teilen</button>
<button type="button" class="textknopf" data-neu>Neu starten</button>
<p class="teilen-status" role="status" data-teilen-status></p>
</div>` : ''}
<div class="alternativen"><h3 class="mono">Auch passend</h3><ul>${r.alternativen.map(id => `<li class="alt">${flasche(id, false)}<div><b>${PRODUKTE[id].name}</b><span>${PRODUKTE[id].wenn}</span><span class="ziffern">${eur.format(PRODUKTE[id].preis)}</span></div></li>`).join('')}</ul></div>`;
}
function zeigeErgebnis(fokus) {
const r = auswerten(zustand.antworten);
zustand.fertig = true;
schreibeUrl();
const tun = () => {
form.hidden = true; ergebnis.hidden = false; document.body.classList.add('hat-ergebnis');
ergebnis.innerHTML = ergebnisHtml(r);
$('[data-titel]').textContent = 'Das passt zu deiner Haut.';
};
html.style.setProperty('--richtung', 1);
if (document.startViewTransition && !reduziert()) {
ergebnis.style.viewTransitionName = 'frage';
const vt = document.startViewTransition(() => { fragenEl.innerHTML = ''; tun(); });
vt.finished.then(() => { ergebnis.style.viewTransitionName = ''; });
if (fokus) vt.updateCallbackDone.then(() => $('#erg-titel').focus({ preventScroll: true }));
} else { tun(); if (fokus) $('#erg-titel').focus({ preventScroll: true }); }
zeigeBild(r.sieger, flasche(r.sieger), `${PRODUKTE[r.sieger].name} · ${PRODUKTE[r.sieger].menge}`);
}
function live() {
const offen = zustand.antworten.filter(a => a === null).length;
if (offen) {
$('[data-schild]').hidden = true; $('[data-schild-name]').textContent = '';
ergebnis.hidden = false;
ergebnis.innerHTML = `<p class="offen-hinweis">Noch ${offen === 1 ? 'eine Frage' : offen + ' Fragen'} bis zur Empfehlung.</p>`;
zeigeBild('gruppe', gruppe(), 'Serien · 30 und 50 ml');
return;
}
const r = auswerten(zustand.antworten);
ergebnis.hidden = false;
ergebnis.innerHTML = ergebnisHtml(r);
const sch = $('[data-schild]'), pr = PRODUKTE[r.sieger];
if ($('[data-schild-name]').textContent !== pr.name) {
$('[data-schild-name]').textContent = pr.name; $('[data-schild-formel]').textContent = `${pr.formel} · ${eur.format(pr.preis)}`;
if (!reduziert()) sch.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 250, easing: 'cubic-bezier(0.25, 1, 0.5, 1)' });
}
sch.hidden = false;
zeigeBild(r.sieger, flasche(r.sieger), `${PRODUKTE[r.sieger].name} · ${PRODUKTE[r.sieger].menge}`);
}
// ---------- URL: ?f=2131 (Antworten 1-basiert, 0 = offen) ----------
function schreibeUrl() {
const q = new URLSearchParams(location.search);
const code = zustand.antworten.map(a => a === null ? 0 : a + 1).join('');
if (/^0+$/.test(code)) q.delete('f'); else q.set('f', code);
history.replaceState(null, '', (q.toString() ? '?' + q.toString().replace(/=(&|$)/g, '$1') : location.pathname));
}
function leseUrl() {
const f = new URLSearchParams(location.search).get('f') || '';
if (!/^[0-9]{4}$/.test(f)) return false;
const a = [...f].map((z, i) => { const n = +z - 1; return n >= 0 && n < FRAGEN[i].antworten.length ? n : null; });
zustand.antworten = a;
return a.every(x => x !== null);
}
function teilLink() {
const q = new URLSearchParams(location.search); q.delete('vorschau'); q.delete('reduziert');
return location.origin + location.pathname + '?' + q.toString();
}
document.addEventListener('click', async e => {
if (e.target.closest('[data-neu]')) { neustartFinder(true); return; }
const t = e.target.closest('[data-teilen]'); if (!t) return;
const status = $('[data-teilen-status]'), url = teilLink(), p = PRODUKTE[auswerten(zustand.antworten).sieger];
try {
if (navigator.share && matchMedia('(hover: none)').matches) { await navigator.share({ title: `Meine Sameta-Empfehlung: ${p.name}`, url }); status.textContent = 'Geteilt.'; return; }
await navigator.clipboard.writeText(url);
status.textContent = 'Link kopiert. Wer ihn öffnet, sieht dieselbe Empfehlung.';
} catch (err) {
if (err && err.name === 'AbortError') return;
status.innerHTML = 'Kopieren ging nicht. Hier ist der Link zum Markieren:<input type="text" readonly aria-label="Link zum Ergebnis">';
const inp = status.querySelector('input'); inp.value = url; inp.focus(); inp.select();
}
});
function neustartFinder(fokus) {
zustand.antworten = [null, null, null, null]; zustand.schritt = 0; zustand.fertig = false;
schreibeUrl();
$('[data-titel]').textContent = 'Welche Pflege passt zu deiner Haut?';
zeigeBild('gruppe', gruppe(), 'Serien · 30 und 50 ml');
if (zustand.variante === 'live') { aufbauLive(); return; }
ergebnis.hidden = true; ergebnis.innerHTML = ''; form.hidden = false; document.body.classList.remove('hat-ergebnis');
fragenEl.innerHTML = '';
zeigeSchritt(0, -1, fokus);
}
function aufbauLive() {
form.hidden = false;
fragenEl.innerHTML = '<div class="fragen-live">' + FRAGEN.map((_, i) => frageHtml(i)).join('') + '</div>';
live();
}
// ---------- Variante (?v=) ----------
const VARIANTEN = ['schritte', 'live'];
const WERTE = {
schritte: 'Kurve cubic-bezier(0.25, 1, 0.5, 1) · Frage 250 ms rein, 150 ms raus, 16 px · Auswahl 250 ms · Bild 1000 ms Überblendung',
live: 'Kurve cubic-bezier(0.25, 1, 0.5, 1) · Auswahl 250 ms · Ergebnis sofort, Bild 1000 ms Überblendung'
};
function variante(v, start = false) {
if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
zustand.variante = v; document.body.dataset.variante = v;
document.querySelectorAll('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
$('[data-werte]').textContent = WERTE[v];
const q = new URLSearchParams(location.search);
if (q.get('v') !== v) { q.set('v', v); history.replaceState(null, '', '?' + q.toString().replace(/=(&|$)/g, '$1')); }
const komplett = start ? leseUrl() : zustand.antworten.every(a => a !== null);
if (v === 'live') { aufbauLive(); return; }
$('[data-schild]').hidden = true; $('[data-schild-name]').textContent = '';
ergebnis.hidden = true; form.hidden = false; fragenEl.innerHTML = ''; document.body.classList.remove('hat-ergebnis');
if (komplett) { zeigeErgebnis(false); return; }
const erste = zustand.antworten.findIndex(a => a === null);
zeigeSchritt(Math.max(0, erste), 1, false);
zeigeBild('gruppe', gruppe(), 'Serien · 30 und 50 ml');
}
document.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => { if (html.classList.contains('vorfuehrung')) neustart(); variante(b.dataset.v); }));
// ---------- Vorführung ----------
let zeitgeber = [], laeuft = false;
const nach = (ms, f) => zeitgeber.push(setTimeout(f, ms));
function tippe(el) { if (!el) return; el.classList.remove('tipp'); void el.offsetWidth; el.classList.add('tipp'); }
function waehle(i, j) {
const inp = fragenEl.querySelector(`input[name="f${i}"][value="${j}"]`); if (!inp) return;
tippe(inp.closest('.option')); inp.checked = true; inp.dispatchEvent(new Event('change', { bubbles: true }));
}
function drueck(el) { tippe(el); el.click(); }
function eineRunde() {
neustartFinder(false);
if (zustand.variante === 'live') {
[[0, 1], [1, 0], [2, 1], [3, 0]].forEach(([i, j], k) => nach(900 + k * 1100, () => waehle(i, j)));
nach(7600, () => waehle(1, 1)); // Anliegen ändern: Empfehlung wechselt
nach(10400, () => waehle(2, 2));
nach(13600, eineRunde);
return;
}
nach(900, () => waehle(0, 1));
nach(1700, () => drueck(weiter));
nach(2700, () => waehle(1, 0));
nach(3500, () => drueck(weiter));
nach(4500, () => waehle(2, 2));
nach(5300, () => drueck(zurueck)); // Zurück: Antwort bleibt gewählt
nach(6500, () => drueck(weiter));
nach(7300, () => drueck(weiter));
nach(8300, () => waehle(3, 0));
nach(9100, () => drueck(weiter)); // Empfehlung mit Begründung
nach(14600, eineRunde);
}
function pause() { zeitgeber.forEach(clearTimeout); zeitgeber = []; laeuft = false; }
function neustart() { pause(); html.classList.remove('vorfuehrung'); neustartFinder(false); }
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); laeuft = true; eineRunde(); }
variante(new URLSearchParams(location.search).get('v'), true);
})();
</script>
</body>
</html>