/* ============================================================= Ciclo — Landing page composition (CREAM EDITORIAL VERTICAL SECTIONS) Inspired by HappyRobot.ai: classic vertical sections, big display headlines with weight contrast, numbered modules, flat cards. ============================================================= */ (() => { const { useState, useEffect } = React; const { Logo, LangToggle, Thread, Screenshot } = window.UI; /* ---------- Contact ---------- Every "Agenda una demo" CTA opens WhatsApp with a prefilled message. The number lives here once; the message text comes from i18n so each locale opens the thread in its own language. E.164 with no + or spaces — the format wa.me expects. */ const WHATSAPP_NUMBER = '13322073372'; const waLink = (message) => `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(message)}`; /* Opens the WhatsApp app (or web.whatsapp.com) in a new tab, so the visitor keeps the landing page behind them. */ const waProps = (message) => ({ href: waLink(message), target: '_blank', rel: 'noopener noreferrer', }); /* ---------- Nav ---------- */ const Nav = ({ t, navBg, lang, setLang }) => ( ); /* ---------- Hero — conversation, and the order it produced ---------- Replaces the old hero screenshot. Left: a real-shaped WhatsApp exchange (Thread). Right: a static order card built from the same tokens, sharing the customer, branch and pickup slot the thread just agreed on — the correspondence between the two panels is the pitch, not decoration around it. No service/items field: the bot never asks what the customer is washing, so the conversation never establishes one (a person records items at the branch). The handoff moment is deliberately absent here; it gets its own dedicated thread in the arc. */ const OrderCard = ({ o }) => (
{o.folio} {o.stage}
{o.customer_label}
{o.customer}
{o.address_label}
{o.address}
{o.branch_label}
{o.branch}
{o.window_label}
{o.window}

{o.note}

); const Hero = ({ t, lang }) => (
{t.hero.badge}

{t.hero.h1}

{t.hero.sub}

{t.hero.cta_primary} {t.hero.cta_ghost} →

{t.hero.trust}

); /* ---------- Screens — the one real screenshot on the page ---------- Everywhere else in the arc, a product-shaped visual stands in for a screenshot on purpose (see Board's and ZoneMap's comments: a real 1440×900 capture renders at ~0.40× in that ~570px media column, illegible before it ships). This section exists to be the opposite of that — the "here is the whole application" moment the page lost when the original hand-built mocks were pulled. `.container-wide` (1440px, base.css) instead of the page's usual 1200px `.container` so the image gets real width: assets/screens/dashboard.webp is a 2× capture of the dashboard at its own 1440-wide desktop layout (2880×1800 intrinsic), so at a 1440px+ viewport this section displays it at that native 1:1 CSS size — full crispness on a retina screen, not a downscaled crop. Placed directly after Hero, before SectionJump/Arc: the hero's pitch is a WhatsApp thread and the order it produced; this is where that order lands. `data-bg="off"` (bright white) sits between the hero's `tint` and `#how`'s `cream`, so no two adjacent sections share a surface. Not registered in the nav/SectionJump — both are already at capacity in Spanish under ~900px (~70px of slack), and this section's framing line already tells a scrolling visitor what they're looking at, so a nav entry would be redundant with the copy right above it. Real screenshot, demo data: captured from the running dashboard against a synthetic org ("Lavandería Aurora"), not a customer's live numbers — `t.screens.caption` says so explicitly, under the image, so nothing here reads as someone's real operation. */ const Screens = ({ t }) => (

{t.screens.h}

{t.screens.sub}

{t.screens.caption}
); /* ---------- Section jump — mobile-only in-page section nav ---------- Stands in for the header's .nav-links row, which is `display: none` below 880px (see .section-jump's comment in landing.css for the full rationale: no JS drawer, a plain static anchor row instead). Reuses the same five anchors and the same t.nav.* labels the desktop header links use, so no new i18n keys. Rendered once, right after Hero and before Arc — never inside Hero — so it can't affect what's above the fold on first load; it only becomes visible once the visitor scrolls to it. */ const SectionJump = ({ t, lang }) => ( ); /* ---------- Stamp card — arc part 04's visual ---------- The stamp count is inherently a count, so it renders as a punch-card grid of filled/empty dots (geometry: dots), not a hairline-row list — the 4th and last distinct shape in the arc (bubbles → columns → map → dots). Verified against apps/api/src/common/loyalty-progress.ts: punchCountsFor only counts orders where order.status === 'completed' (line 65) toward a customer_history buy_n_get_free promo, and loyaltyProgressFor's `target` is the promotion's own `buyQuantity` field (line 147) — the number of dots drawn here IS that target, not an arbitrary round number. apps/api/src/modules/promotion/ promotion.service.ts (line 419) rejects a buy_n_get_free promo against any category whose pricing_unit isn't 'per_item', which is why this card's promo is scoped to a per-piece category ("Camisas") rather than a per-kilo one — a laundromat's by-the-kilo loads can't run this promotion at all, so illustrating it on shirts is not an arbitrary choice. The membership state below reuses membership-benefits.ts membershipSummaryFor's fields (status derived, never stored; percentOff/freeDelivery independent, combinable plan fields) but folds them into one sentence instead of a second stacked dt/dd block, so the card doesn't quietly reintroduce the hairline-row shape it exists to replace. The dot grid is aria-hidden; the count is also rendered as text so it isn't screen-reader-invisible. */ const StampCard = ({ t }) => (
{t.stamp.label} {t.stamp.chip}

{t.stamp.progress_sr}

{t.stamp.progress}/{t.stamp.target}

{t.stamp.note}

{t.membership.label} {t.membership.status}

{t.membership.plan} — {t.membership.benefit}. {t.membership.renews_label} {t.membership.renews}.

); /* ---------- Board — arc part 02's visual ---------- The board's real metaphor is parallel stage columns, not a vertical list — this renders three lane columns (geometry: columns, the 2nd of the arc's four distinct shapes) plus one order sitting outside all of them, exactly like the real board does. There was never a real 'opera' capture to begin with (a 1440×900 board screenshot renders at ~0.40× in this arc's ~570px column, illegible before it even ships) — the same situation StampCard replaced for arc 04. `unconfirmed` / #4821 / Renata Vidal is deliberately the SAME order the hero's OrderCard shows — so this panel and the hero read as one continuous claim, not two unrelated mocks: the bot schedules it unconfirmed, and here it sits outside every lane exactly as promised, while orders a dispatcher already confirmed occupy pickup/processing/ delivery. That's the hero's "ningún pedido del bot se agenda solo" claim, shown again from the operator's side of the board. Stage taxonomy verified against apps/api/src/schema/ lifecycle-stage-type.ts (lines 12-14) — a CHECK constraint restricts `key` to 'pickup' | 'delivery' | 'processing', no other stage exists, which is exactly the three lanes rendered — and apps/api/src/hatchet/ workflows/agent/tools.ts's scheduleRecoleccion (line 1130; the "stage-less" comment at line 1162): a bot-created order inserts with `currentStageId: null` until a dispatcher calls POST /orders/:id/confirm, which is why `unconfirmed` is modeled as its own field, structurally outside the `stages` array, rather than as a fourth lane or a stage_kind on a shared row shape. The unconfirmed chip's color is matched to the product's real badge, not invented: order-status-pill.tsx's PENDING_CONFIRMATION_CONFIG (lines 53-57) renders "Por confirmar" as a warning/orange pill, which is what --chip-proc (warning orange) stands in for here — its name suggests "processing," but the color, not the name, is what's being matched; the processing LANE itself carries no chip color at all in this shape (lanes are typed by position, not by a colored badge). */ const Board = ({ t }) => (
{t.label}
{t.unconfirmed.folio} {t.unconfirmed.customer} {t.unconfirmed.chip}
{t.stages.map((s, i) => (
{s.label} {s.orders.length}
{s.orders.map((o, j) => (
{o.folio} {o.customer}
))}
))}
); /* ---------- Zone map — arc part 03's visual ---------- An inline SVG polygon standing in for a drawn delivery zone (geometry: map, the 3rd of the arc's four distinct shapes), with a compact numbered fee ladder beneath it — no image file, no map tiles, no network request; a real map screenshot at 1440×900 renders illegibly in this arc's ~570px column anyway, and there was never a real 'entrega-zonas' capture to begin with. Zones are genuinely drawn on-screen in the product, not a landing-page invention: apps/api/src/ schema/zone.ts stores each zone's `boundary` as a `geographyPolygon` (line 15), and apps/web/src/components/dashboard/settings/ zone-map.tsx configures mapbox-gl-draw with `defaultMode: "draw_polygon"` (line 97) — an operator draws an irregular polygon on a live map, which is what the hand-drawn (non-circular, non-square) polygon points below represent, not a generic map icon. The fee ladder shows exactly the two rule types this page already claims in its own prose (arc part 03's body and the FAQ's shipping answer): "gratis desde $300" / "free above $300" and "$12 por kilómetro" / "$12/km" — it dramatizes numbers already on the page, it does not invent new ones. Verified against apps/api/src/modules/ delivery-fee/delivery-fee-engine.ts: `evaluateDeliveryFee` sorts rules by `priority` ascending and returns the fee of the FIRST rule whose condition matches (line 68). An `order_value_gte` rule charging `free` ranked ahead of an `always` rule charging `per_km` is exactly this two-rule shape — the `always` rule only ever fires because the free rule's condition didn't match (order subtotal below $300), so "cualquier otro pedido" / "any other order" describes it accurately, not as an invented catch-all. */ const ZoneMap = ({ t }) => (
{t.label}

{t.branch_label}

    {t.rules.map((r, i) => (
  1. {r.condition} {r.charge}
  2. ))}

{t.note}

); /* ---------- Arc — the four-part product narrative ---------- Replaces HowItWorks + Modules: one story (receive → operate → deliver → retain), each part pairing prose with a visual. Deliberately FOUR different geometries, not four skins on one shape: 01/Thread renders chat bubbles (unchanged — the page's signature element), 02/Board renders parallel stage columns, 03/ZoneMap renders an inline SVG polygon plus a fee ladder, 04/StampCard renders a dot grid. None of them is a screenshot. The check is "which data key does this part supply," not "which index is this." The 01–04 numbering survives here and only here, where sequence carries real meaning. */ const Arc = ({ t, lang }) => (
{t.arc.parts.map((p, i) => (
{p.num}

{p.name}

{p.body}

    {p.feats.map((f, j) =>
  • {f}
  • )}
{p.retention ? ( ) : p.thread ? ( ) : p.board ? ( ) : p.zoneMap ? ( ) : null}
))}
); /* ---------- Driver route — the portrait screenshot's own moment ---------- assets/screens/driver-route.webp is 1170×2532 — portrait, the only portrait image on the page; every other capture (dashboard, reports, attendance) is a 2880×1800 desktop screen. Every other visual on the page, real or product-shaped, reads landscape-or-wider; a phone screen is the one silhouette that can't and shouldn't be forced into that mold — sizing it into a wide slot would shrink it into illegibility for no reason, since a portrait image doesn't need width, it needs height, which this page has plenty of. Placement: arc part 03 ("Entrega") already carries this section's visual — the inline SVG zone map + fee ladder — and the arc's own framing is deliberate: "None of them is a screenshot" (see Arc's comment above). Dropping a real capture into that slot, beside or instead of the SVG, would break the one rule that keeps the arc's four parts reading as one system rather than four random widgets. So this gets its own moment immediately after the arc closes instead — still "nearby" the delivery story, not interrupting the 01–04 sequence. Part 03's body copy used to also state the driver-route claim in prose ("Tus repartidores ven la ruta del día en su teléfono") before this section existed to show it; now that this section makes the claim in full (plus the "App de repartidores" pill), the arc body no longer restates it — same claim, said once instead of twice (task: mobile scroll length). `data-bg="off"` between the arc's `cream` and #asistente's `navy` — no two adjacent sections share a surface. Copy describes only what's visibly in the crop (verified by viewing the file directly): stops numbered 1–8 on a map, green pins for completed stops, blue/purple for pending, a small package icon on pickup pins and a house icon on delivery pins, and a bottom card reading "PRÓXIMA RECOLECCIÓN" / "4/8". No tracking-link claim here — that's a different feature (#incluye's "Seguimiento por link" row) and isn't what this crop shows. */ const DriverApp = ({ t }) => (

{t.driver.h}

{t.driver.sub}

{t.driver.caption}
); /* ---------- Assistant — its own section ---------- Used to pair a hand-built Thread (an invented Q&A — "$18,430 en 61 pedidos") with a screenshot of the Reportes screen: wrong screen for a section about the assistant, and an invented answer sitting right next to it (task: assistant screenshot defect). Both are gone. This is now a single real capture — assets/screens/assistant.webp, taken by actually driving the product against the synthetic "Lavandería Aurora" org, opening /dashboard/chat, and asking the same question the old thread invented an answer for. The real LLM's real reply is what's in the crop, so there's no separate Thread claiming a different number ("$3,464.95 en 20 órdenes" per the "Nuevos" row is the crop's real figure) beside it. Thread itself is untouched — the hero and arc part 01 still use it; this section simply stopped being its third appearance. This is the page's one deliberately inverted band (task: page rhythm — nine-plus screens of light surfaces was the core complaint). Short, self-contained, single-image now, so the inversion is carried by the heading/lede plus one on-dark screenshot frame rather than a thread-plus-image pair. `.surface-ink-bg` + the `#asistente`-scoped overrides in landing.css use only the existing on-dark token family the footer already established (--ink, --hairline-on-dark(-soft), --body-on-dark(-muted)) — no new colors. Copy describes only what's visibly in the crop (verified by viewing the file directly): a table of order status/stage counts, a bolded "Lectura rápida" summary, a "Mejores clientes de la semana" table (name, revenue, order count) for five named customers, a "Clientes nuevos vs recurrentes" table, a second bolded summary, and a closing offer to compare this week against last week. `t.assistant.alt` / `t.assistant.caption` follow the same naming and framing as every other single-screenshot section on the page (Screens, DriverApp, Attendance) — no `report_`/`thread_` prefix left over from the old two-up layout. Single centered column (`.assistant-figure`, capped at 1040px like the old two-up row's combined width) rather than the former two-column thread-plus-image grid — one image needs one column, not a breakpoint. The image's border/shadow reuse the existing on-dark hairline token (`--hairline-on-dark`), already established by the scoped rules below — no new colors. */ const Assistant = ({ t }) => (

{t.assistant.h}

{t.assistant.sub}

{t.assistant.caption}
); /* ---------- Included — the spec table, now a multi-column index ---------- Was a single-column `dl` — 6 rows × up to 5 items = 24 hairline rows stacked in one ~1792px column, nearly two phone screens of the same shape repeated (task: back-half variety, measured). Same content, grouped into an index instead: a CSS grid of the 6 groups, 2 columns from the smallest width up, 3 at desktop (980px+) — no 1-column mobile step, because that step was still ~1.8 phone screens on its own (task: mobile scroll length, measured): 6 groups stacked with nothing left to shrink, just a lot of them. 2-up halves that to 3 rows for the identical 6 groups / 24 items, so the section reads as a squarish reference block at every width, not a scroll. Density is still the point (every capability from every group is still here, still one per line) — only the silhouette changes. No `dl`/`dt` here anymore: each group is a real `

` (heading semantics a `dt` never carried) at body-text size/weight rather than the page's uppercase-mono micro-label — the group name IS the grouping, so it earns hierarchy through size and weight, not a caps treatment identical to a dozen other unrelated labels on the page (task: micro-label monopoly). No cards — a plain hairline rule above each group is the only chrome, same restraint as before. */ const Included = ({ t }) => (

{t.included.h}

{t.included.sub}

{t.included.rows.map((r, i) => (

{r.k}

    {r.items.map((it, j) =>
  • {it}
  • )}
))}
); /* ---------- Attendance — staff clock-in, now carried by a real screenshot ---------- Sits outside the recibe/opera/entrega/retiene arc on purpose (staff attendance isn't part of the customer-facing delivery story), so it doesn't interrupt the arc's flow — placed right after #incluye instead, next to the "Personal" spec row it elaborates on, the same way OrderModels elaborates on the pricing-unit row directly below it. Previously a fabricated three-employee roster (Marisol G./Iván R./ Paola T.) under a full `.h2`, then three `.arc-feats` pills asserting PIN clock-in / adjustable tolerance / a daily summary. assets/screens/ attendance.webp (captured against the synthetic "Lavandería Aurora" org, PII-verified) now shows all three of those claims directly — the PIN keypad and live clock, each employee's 07:00–16:00 schedule, and the per-employee "En turno"/"Tarde"/"Falta" state — so the pills are cut rather than restyled: the image carries what they used to assert. `.h3`-weight heading, `.section--tight` padding (roughly half of `--section-y`), text beside the image at 760px+ (matching the hero-convo/compare breakpoint), stacked text-then-image at mobile. Still no cards, no invented names, no roster. Every claim in `sub`/ `note` remains literal product behavior, unchanged from before: - pin.util.ts: hashPin/verifyPin — the 4-digit PIN is scrypt-hashed and timing-safe compared, never stored or checked as plaintext. - dto/punch.dto.ts: PunchDto requires branchId + a pin matching `/^\d{4}$/` + type ('check_in'|'check_out') on every punch — the clock happens at a branch, not the PIN alone. - schedule-resolution.ts: DEFAULT_TOLERANCE_MINUTES = 15; resolveSchedule reads a per-branch, per-day-of-week schedule first, then an optional per-employee, per-day override (including its own toleranceMinutes) — the screenshot's uniform 07:00–16:00 is one branch's resolved schedule, not a claim that every branch shares it. - daily-summary.ts: buildDailySummaries flags 'late' when firstIn is after scheduledStart + tolerance and 'absent' when there are no punches by then — exactly the states visible in the roster and explained by `note` (hours = lastOut − firstIn is also computed there, but isn't claimed here or shown in the crop). */ const Attendance = ({ t }) => (

{t.attendance.h}

{t.attendance.sub}

{t.attendance.note}

{t.attendance.caption}
); /* ---------- Order models — two pricing units, one per category ---------- Replaces the two-card "audience" pattern (identical bordered cards, gradient stripe, pill cloud) that said nothing a shop owner couldn't have guessed. Every category in the catalog carries a pricing_unit of either per_kilo or per_item (apps/api/src/schema/category-type.ts, a DB CHECK constraint — there are only ever these two). That single field changes what a counter line captures and how its total is computed: loadOrderItems in apps/api/src/common/order-items.ts derives lineTotal as `price * weight` for a per_kilo line and `price * quantity` for a per_item line, and assertItemsWeighed in the same file rejects a per_kilo line with no weight recorded. The promotion note is real too: promotion.service.ts (~line 419) rejects a buy_n_get_free rule against any category whose pricing_unit isn't per_item — a laundromat's per-kilo loads can't run that promotion, a dry cleaner's per-garment lines can. Same section slot as the old cards (id="audience"); no cards, no stripes, no pill cloud — two hairline-separated spec lists (sentence-case dt, 16px dd — see .compare-row dt's comment in landing.css for why the dt dropped its uppercase-mono treatment), stacked at mobile so one model reads fully before the other. Deliberately NOT framed as "the same order, two ways" — a 5.4kg per-kilo load and a 2-shirts-plus-a-pair-of- pants per-item order are two different illustrative orders, and claiming they're the same one doesn't survive a reader doing the arithmetic. Each column's total is independently checkable from the per-unit prices in its own example line. The peso/prenda figures are illustrative, not real Ciclo pricing. */ const OrderModels = ({ t }) => (

{t.audience.h}

{t.audience.sub}

{t.audience.models.map((m, i) => (

{m.name}

{t.audience.labels.unit}
{m.unit}
{t.audience.labels.capture}
{m.capture}
{t.audience.labels.example}
{m.example}
{t.audience.labels.total}
{m.total}
))}

{t.audience.note}

); /* ---------- Demo / pricing CTA — now price-led ---------- `price` + `price_unit` are the only price signal on the page — see i18n.jsx's comment on `demo`. This is the page's money moment, and it used to look exactly like every other hairline-row section below the arc: an `.h2` headline first, the price a small line beneath it, then a three-item checklist (task: back-half variety, measured at 914px of checklist-shaped everything). Reordered so the price is the first and largest thing in the block — `.price-amount` now reads bigger than the `.h3` headline beneath it, not the other way around — and the checklist is gone, folded into `points_prose`, one flowing paragraph instead of three ✓-prefixed rows. `price_unit` still sits right beside the number at strong weight (not a muted caption), because the product sells multi-branch as a feature and a bare "$499" would misread as covering the whole operation. */ const DemoCTA = ({ t }) => (

{t.demo.price_eyebrow}

{t.demo.price} {t.demo.price_unit}

{t.demo.h}

{t.demo.sub}

{t.demo.points_prose}

{t.demo.cta}

{t.demo.note}

); /* ---------- FAQ ---------- */ const FAQ = ({ t }) => { const [openIdx, setOpenIdx] = useState(0); return (

{t.faq.h}

{t.faq.side}

{t.faq.items.map((it, i) => { const isOpen = openIdx === i; return (
); })}
); }; /* ---------- CTA closer ---------- */ const CTA = ({ t }) => (

{t.cta_block.h}

{t.cta_block.sub}

{t.cta_block.primary} {t.cta_block.ghost}
); /* ---------- Footer ---------- */ const Footer = ({ t, lang, setLang }) => (

{t.footer.tag_a}{t.footer.tag_em}{t.footer.tag_b}

{t.footer.cols.map((col, i) => (

{col.h}

{col.links.map((l, j) => ( /* `wa: ''` resolves to a WhatsApp link with that locale's prefilled message, so the number stays in one place. Everything else is a plain href. */ e.preventDefault() : undefined, })} > {l.label} ))}
))}
{/* Below 768px the nav LangToggle is hidden — this is the only reachable one at that width; hidden itself at 768px and up. */}
{t.footer.base_left} {t.footer.base_right}
); /* ---------- App ---------- */ const App = () => { const [lang, setLang] = useState('es'); /* Matches Hero's data-bg ('tint') so there's no flash to the wrong nav color before the scroll-position effect below runs on mount. */ const [navBg, setNavBg] = useState('tint'); useEffect(() => { document.documentElement.lang = lang; }, [lang]); /* Neutralize dead-link clicks (href="#") so they don't jump to top. Real anchors (#how, #incluye, #pricing, etc.) are unaffected. */ useEffect(() => { const onClick = (e) => { const a = e.target.closest && e.target.closest('a[href="#"]'); if (a) e.preventDefault(); }; document.addEventListener('click', onClick); return () => document.removeEventListener('click', onClick); }, []); /* Scroll reveal — flag as reveal-ready (so content stays visible if JS never runs), then mark each [data-reveal] element on first entry. */ useEffect(() => { if (!('IntersectionObserver' in window)) return; document.documentElement.classList.add('reveal-ready'); const els = document.querySelectorAll('[data-reveal]'); const io = new IntersectionObserver((entries) => { entries.forEach((en) => { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }); }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 }); els.forEach((el) => io.observe(el)); return () => io.disconnect(); }, []); /* Track which [data-bg] section is at the top of the viewport (just below the nav) — pick that section's bg as the nav bg. */ useEffect(() => { const sections = document.querySelectorAll('[data-bg]'); if (!sections.length) return; const NAV_HEIGHT = 64; const pickActive = () => { let best = null; let bestDist = Infinity; sections.forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.bottom < NAV_HEIGHT) return; // already scrolled past const dist = Math.abs(rect.top - NAV_HEIGHT); if (rect.top <= NAV_HEIGHT + 1 && dist < bestDist) { best = el; bestDist = dist; } }); if (best) setNavBg(best.dataset.bg); else setNavBg(sections[0].dataset.bg); }; pickActive(); window.addEventListener('scroll', pickActive, { passive: true }); window.addEventListener('resize', pickActive); return () => { window.removeEventListener('scroll', pickActive); window.removeEventListener('resize', pickActive); }; }, []); const t = window.I18N[lang]; return ( <>