// ============================================================ // SECTION STORY — Scroll-driven 6-phase narrative // • Photo + text alternating layouts (Bahan Alami / Multimanfaat / Praktis) // • Hero certification reveal that morphs into "Yojea" + description // • Final phase: photo slides left, purchase channels appear right // • Background colors transition between phases via interpolation // • SVG wave dividers replace flat horizontal section boundaries // ============================================================ // ─── CircularSeal — a rotating "deal sticker" with curved text ── // SVG textPath rides a hidden circle so the label loops; the // outer wrapper spins via CSS using --spin (duration) and --dir. function CircularSeal({ label, main, sub, style, color, bg }) { const id = React.useId(); return (
{label} {label}
{main} {sub}
); } function SectionStory() { const sectionRef = React.useRef(null); const buyRef = React.useRef(null); const [progress, setProgress] = React.useState(0); // 0..1 across full scroll const [isMobile, setIsMobile] = React.useState(false); const [buyH, setBuyH] = React.useState(300); // measured buy-box height (mobile seal anchor) // ─── Mobile flag (collapses some layouts to single column) ── React.useEffect(() => { const mq = window.matchMedia('(max-width: 760px)'); const update = () => setIsMobile(mq.matches); update(); if (mq.addEventListener) mq.addEventListener('change', update); else mq.addListener(update); return () => { if (mq.removeEventListener) mq.removeEventListener('change', update); else mq.removeListener(update); }; }, []); // ─── Measure the buy box so mobile seals sit just above it ─── React.useEffect(() => { if (!isMobile) return; const measure = () => { if (buyRef.current) setBuyH(buyRef.current.offsetHeight || 300); }; measure(); window.addEventListener('resize', measure); const t = setTimeout(measure, 400); // after fonts/layout settle return () => { window.removeEventListener('resize', measure); clearTimeout(t); }; }, [isMobile]); // ─── Scroll progress — drives every phase, computed via rAF ── React.useEffect(() => { let raf = 0; const compute = () => { raf = 0; if (!sectionRef.current) return; const rect = sectionRef.current.getBoundingClientRect(); const total = rect.height - window.innerHeight; if (total <= 0) { setProgress(0); return; } const p = Math.max(0, Math.min(1, -rect.top / total)); setProgress(p); }; const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); }; window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll, { passive: true }); compute(); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (raf) cancelAnimationFrame(raf); }; }, []); // ─── Phase math ────────────────────────────────────────────── // 6 phases evenly distributed across progress (0..1). // For each phase we compute "presence" — how fully visible it is // (peaks at the center of its scroll window, fades at edges). const PHASE_COUNT = 5; const phaseLocal = (i) => { // 0..1 within this phase's own scroll range const span = 1 / PHASE_COUNT; return Math.max(0, Math.min(1, (progress - i * span) / span)); }; // Bell curve: 0 at edges, 1 in the middle 30-70% of the phase const presence = (i) => { const t = phaseLocal(i); if (t <= 0 || t >= 1) return 0; // Smooth in/out with a 25% ramp on each side if (t < 0.25) return t / 0.25; if (t > 0.75) return (1 - t) / 0.25; return 1; }; // ─── Background color interpolation ────────────────────────── // 6 stops, one per phase. Smooth crossfade based on progress. const BG_STOPS = [ '#F4ECDD', // 0 — Bahan Alami: warm cream '#EAD9C0', // 1 — Multimanfaat: deeper cream '#F0E1CB', // 2 — Praktis: peachy beige '#E8D5BC', // 3 — Photo right: warm sand (matches photo 5 bg) '#F1E1C8', // 4 — Bingung Beli: slightly lighter to lift CTA ]; const bgColor = (() => { const t = progress * (BG_STOPS.length - 1); const i = Math.min(BG_STOPS.length - 2, Math.floor(t)); const local = t - i; return mixHex(BG_STOPS[i], BG_STOPS[i + 1], local); })(); // ─── Phase data (first 3 alternating photo/text rows) ──────── const ROWS = [ { img: 'assets/story-natural.webp', side: 'left', kicker: '01 · Filosofi', title: ['Bahan', 'Alami'], body: 'Diramu dari bahan-bahan herbal Indonesia pilihan. Persis seperti racikan nenek dulu, hanya saja lebih bersih dan terstandar.', }, { img: 'assets/story-multi.webp', side: 'right', kicker: '02 · Manfaat', title: ['Multi', 'manfaat'], body: 'Satu sachet, banyak manfaat. Membantu meredakan nyeri haid, menjaga keseimbangan hormon, mengurangi keputihan, dan mendukung kesehatan kulit dari dalam.', }, { img: 'assets/story-praktis.webp', side: 'left', kicker: '03 · Ritual', title: ['', 'Praktis'], body: 'Tidak perlu rebus, tidak perlu saring. Cukup sobek, minum. Cocok untuk wanita modern yang aktif — kapan saja, di mana saja.', }, ]; // ─── Phase 3-5 (cert hero, morph, buy where) ───────────────── const certPresence = presence(3); const buyPresence = presence(4); // Photo 5 stays mounted from phase 3 onward and morphs across the // sub-range (3/6 .. 6/6 of total). The photo travels: // Phase 4 — centered low | Phase 5 — RIGHT | Phase 6 — LEFT // so the Teruji text (left, persisting from phase 4 through phase 5) // gets clear space, then in phase 6 the buy block + seals take over. const certStart = 3 / PHASE_COUNT; const certEnd = 1.0; const cP = Math.max(0, Math.min(1, (progress - certStart) / (certEnd - certStart))); let photoX = 50; // % from left let photoScale = 1.0; // scale multiplier let photoY = 60; // % from top (anchored bottom-ish) // Speed-up: phase 4 holds the photo for part of cP, then transitions // into phase 5. On MOBILE the hand-off happens earlier (0.5) so the // photo lift, buy block, and seals all animate in together cleanly. const SWEEP_AT = isMobile ? 0.5 : 0.7; if (cP < SWEEP_AT) { // Phase 4 — photo holds on the RIGHT. Slight drift + zoom for life. const local = cP / SWEEP_AT; photoX = isMobile ? 50 : (70 + local * 2); // 70% → 72% photoY = 60; photoScale = 1.0 + local * 0.02; } else { // Phase 5 — quick sweep across to the LEFT, scaling slightly down so // the buy block (right) has visual breathing room. The sweep COMPLETES // by SWEEP_END (before the section ends) and then HOLDS fully-left, so // there's a stable "fully moved, no phase-4 text" zone for the Beli // Sekarang button to land in while content is still pinned. const SWEEP_END = SWEEP_AT + (1 - SWEEP_AT) * 0.62; // desktop ≈ 0.886 const local = Math.min(1, (cP - SWEEP_AT) / (SWEEP_END - SWEEP_AT)); // ease-out so motion is fast at start and settles const eased = 1 - Math.pow(1 - local, 2); photoX = 72 - eased * (isMobile ? 22 : 38); photoY = 62 + eased * (isMobile ? 6 : 0); photoScale = 1.02 - eased * (isMobile ? 0.22 : 0.08); } // ─── Photo inline style ───────────────────────────────────── // DESKTOP: photo travels right→left (top/left/scale interpolated above). // MOBILE: photo is centred & anchored to the BOTTOM. In phase 4 it stands // big at the bottom and rises in from below; in phase 5 it lifts UP so // the "cara beli" block has room beneath it. let photoInlineStyle; if (isMobile) { // Rise-in entrance over the first slice of the finale. const riseIn = Math.max(0, 1 - cP / 0.12); // 1 → 0 const riseY = riseIn * 110; // px up-from-below let bottomPct = 0; // glued to bottom in phase 4 let mScale = 1.0; if (cP >= SWEEP_AT) { const local = (cP - SWEEP_AT) / (1 - SWEEP_AT); const eased = 1 - Math.pow(1 - local, 2); // Lift just enough that her feet land on TOP of the buy box (which // sits ~32% up from the bottom on phones). Less shrink so she stays // big and clearly "stands on" the box rather than floating. bottomPct = eased * 33; mScale = 1.0 - eased * 0.06; } photoInlineStyle = { left: '50%', bottom: bottomPct + '%', top: 'auto', transform: `translate(-50%, ${riseY}px) scale(${mScale})`, opacity: 1 - riseIn * 0.95, }; } else { photoInlineStyle = { left: photoX + '%', top: photoY + '%', transform: `translate(-50%, -50%) scale(${photoScale})`, }; } // Phase 4 "Teruji & Tersertifikasi" overlay — visible only while photo // is on the right (before the sweep). Fades in/out with certPresence; // positioned on the LEFT so it doesn't fight the photo on the right. const certOverlayOpacity = cP < SWEEP_AT ? Math.min(1, certPresence * 1.0) : Math.max(0, 1 - (cP - SWEEP_AT) / (1 - SWEEP_AT) * 2); // ─── Certification seal positions ─────────────────────────── // DESKTOP: stacked vertically on the left over the photo. // MOBILE: small, tight, in a single horizontal row near the top. const sealStyles = isMobile ? [ { top: 'auto', bottom: (buyH + 28) + 'px', left: '15%', width: 64, height: 64, '--spin': '14s' }, { top: 'auto', bottom: (buyH + 28) + 'px', left: '38%', width: 64, height: 64, '--spin': '11s', '--dir': 'reverse' }, { top: 'auto', bottom: (buyH + 28) + 'px', left: '61%', width: 64, height: 64, '--spin': '16s' }, ] : [ { top: '22%', left: '4%', width: 120, height: 120, '--spin': '14s' }, { top: '42%', left: '4%', width: 120, height: 120, '--spin': '11s', '--dir': 'reverse' }, { top: '62%', left: '4%', width: 120, height: 120, '--spin': '16s' }, ]; // ─── Purchase channels ─────────────────────────────────────── const CHANNELS = [ { key: 'tokopedia', label: 'Tokopedia', sub: 'Yojea Official Store', color: '#02AC4F', href: 'https://www.tokopedia.com/yojeaindonesia/jamu-modern-khusus-wanita-ampuh-mengatasi-keputihan-siklus-tidak-lancar-dan-nyeri-haid-1730831920192980029', icon: ( ), }, { key: 'shopee', label: 'Shopee', sub: 'Yojea Indonesia', color: '#EE4D2D', href: 'https://shopee.co.id/product/1361327312/44651499746', icon: ( ), }, { key: 'tiktok', label: 'TikTok Shop', sub: '@yojea.id', color: '#1F1612', href: 'https://vt.tokopedia.com/t/ZS9YEUtpYtaWs-JzrXk/', icon: ( ), }, { key: 'web', featured: true, label: 'Web Yojea', sub: 'Dapatkan harga terbaik!', marquee: 'Beli di website lebih hemat karena tidak terkena biaya administrasi.', reveal: 'Dapatkan Yojea Dengan Harga Paling Hemat!', color: '#C8612B', href: 'https://yojea.id/checkout/', icon: ( ), }, ]; return (
{/* Top wave divider — organic transition from the previous section */}
{/* ─── Phases 1-3: photo + text alternating ─────────── */} {ROWS.map((row, i) => { const p = presence(i); const local = phaseLocal(i); // Direction: slide image in from its side, text from opposite const imgFrom = row.side === 'left' ? -60 : 60; const txtFrom = row.side === 'left' ? 60 : -60; // Subtle parallax once peak presence is reached const drift = (local - 0.5) * 20; return (
0.5 ? 'auto' : 'none', }} aria-hidden={p < 0.5} >
{row.title.filter(Boolean).join(' {row.kicker}
{row.kicker}

{row.title[0] && <>{row.title[0]}
} {row.title[1]}

{row.body}

); })} {/* ─── Phase 4-6: shared photo 5 that morphs ────────── */} {(certPresence > 0 || buyPresence > 0 || cP > 0) && (
{/* Photo (stays mounted, transforms via inline style) */}
Yojea — Teruji dan Tersertifikasi {/* Soft warm halo so the cut-out photo grounds in the bg */}
{/* Phase 4 — "Teruji & Tersertifikasi" overlay on LEFT while the photo sits on the RIGHT. Fades out as cP crosses 0.5 (transition into the brand/buy phase). */}
04 · Kualitas

Teruji &
Tersertifikasi.

BPOM Halal MUI Lab Tested
{/* (Phase 5 brand block — "Yojea" + tagline — removed per spec) */} {/* Phase 5 — Spinning certification seals overlay the photo */}
{/* Phase 5 — "Bingung Beli Yojea Dimana?" + buy channels */}
= 0.56 ? 'auto' : 'none', }} > Beli Yojea

Bingung beli Yojea di mana?

Kamu bisa beli Yojea melalui distributor resmi dan platform e-commerce digital favorit kamu.

)} {/* Phase rail — small indicator showing progress through 6 phases */}
{/* Bottom wave divider — into the next section */}
); } // ─── Helper: interpolate between two hex colors ─────────────── function mixHex(a, b, t) { const pa = parseInt(a.slice(1), 16); const pb = parseInt(b.slice(1), 16); const ar = (pa >> 16) & 0xff, ag = (pa >> 8) & 0xff, ab = pa & 0xff; const br = (pb >> 16) & 0xff, bg = (pb >> 8) & 0xff, bb = pb & 0xff; const r = Math.round(ar + (br - ar) * t); const g = Math.round(ag + (bg - ag) * t); const b2 = Math.round(ab + (bb - ab) * t); return '#' + ((1 << 24) | (r << 16) | (g << 8) | b2).toString(16).slice(1); } Object.assign(window, { SectionStory, CircularSeal });