/* =============================================================
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 }) => (
);
/* ---------- 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 }) => (
);
/* ---------- 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 }) => (
);
/* ---------- 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) => (
{r.step}{r.condition}{r.charge}
))}
{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 }) => (
);
/* ---------- 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 }) => (