# Gumirom v1 — Sell Like Crazy + Hormozi + Magazin MVP — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Restructure the Gumirom homepage on the Sell Like Crazy 17-step spine with a Hormozi Grand Slam Offer, add a static online tyre shop (new + second-hand) with cart and reservation, and fix every defect found in the audit.

**Architecture:** Static site, no build. Pure logic lives in `js/core.js` (UMD: `module.exports` in Node, `window.GumiromCore` in the browser) and is unit-tested with `node --test`. DOM code lives in `main.js` (shared + homepage), `lead.js` (lead delivery), `shop.js` (shop). Business settings in `config.js`, catalogue data in `catalog.js`.

**Tech Stack:** HTML5, CSS (custom properties, no framework), ES5-style vanilla JS (matches existing code), Node 22 `node:test` for unit tests, Python Pillow (one-off) for WebP images.

**Spec:** `v1/docs/superpowers/specs/2026-10-06-gumirom-slc-hormozi-magazin-design.md`

## Global Constraints

- All visible copy in Romanian with correct diacritics (ă â î ș ț, comma-below forms).
- No invented business facts: new commitments carry an HTML/JS comment `DE CONFIRMAT`; demo catalogue shows a visible banner while `GUMIROM_CATALOG.demo === true`.
- No external runtime dependencies, no CDN, no ES modules (must work from `file://` and any static host).
- Scripts load with `defer` in this order: `config.js → core.js → lead.js → main.js` (+ `catalog.js → shop.js` on `magazin.html`).
- Every DOM-touching block in `main.js` guards on element existence (runs on both pages).
- Phone: `+40744398932` / display `0744 398 932`. Address: Str. Liviu Rebreanu nr. 56, Gherla 405300, jud. Cluj. Schedule: L–V 08:00–18:00, Sâmbătă închis, Duminică 09:00–16:00. Payment: numerar la atelier.
- Keep the existing visual system (tokens in `:root`, accent `#c2410c`, dark `#171512`).
- Not a git repo: "commit" steps are replaced by running the test suite; original kept in `v1/_backup/app-original-2026-10-06`.

## Review Focus

1. **Owner edits `catalog.js` by hand with mistakes** (numbers as strings, missing price, duplicate id) → shop must skip bad rows, not crash. Pinned by `sanitizeCatalog` tests in Task 1.
2. **`localStorage` blocked** (Safari private mode, disabled storage) → cart still works in memory for the session. Pinned in Task 5 Step "storage wrapper" (try/catch around every access) + manual check.
3. **Stale cart after catalogue update** (product removed / stock lowered) → cart normalised on load. Pinned by `cartNormalize` tests in Task 1.
4. **Deep link with a size not in stock** (`magazin.html?latime=175&profil=65&diametru=14` from the homepage finder) → selects show the size, empty state offers "Cere ofertă" prefilled. Pinned by `criteriaFromQuery` tests (Task 1) + Task 5 behaviour.
5. **Desktop visitor without SMS app / form not configured** → fallback panel must also offer "Copiază mesajul" and the phone number so the lead is never lost. Pinned in Task 2 (`renderFallback` contract) + manual check.

---

### Task 1: Pure core logic (`core.js`) with unit tests

**Files:**
- Create: `v1/app/js/core.js`
- Test: `v1/tests/core.test.js`

**Interfaces:**
- Produces (`GumiromCore` / `module.exports`):
  - `formatLei(n) → "1.040 lei"`, `escapeHtml(s) → string`, `normalizeText(s) → string`
  - `nowInZone(date, timeZone) → {day:0-6, minutes:0-1439, month:0-11}`
  - `computeOpenStatus({day, minutes}, schedule) → {open:boolean, label:string, minutesLeft?:number}`; `schedule` = array[7] of `null | ["HH:MM","HH:MM"]`, index 0 = Sunday
  - `seasonFor(month) → "iarna" | "vara"` (Sep–Feb = iarna)
  - `parseSize(text) → {latime, profil, diametru} | null`, `formatSize(p) → "205/55 R16"`
  - `sanitizeProduct(raw) → product | null`, `sanitizeCatalog(list) → product[]`, `indexById(list) → {id: product}`
  - `criteriaFromQuery(search) → {latime?, profil?, diametru?, sezon?, stare?, q?, sort?}`, `queryFromCriteria(c) → "?..." | ""`
  - `filterProducts(list, criteria) → product[]` (hides `stoc <= 0`), `sortProducts(list, mode) → product[]` (modes: `recomandate`, `pret-asc`, `pret-desc`, `profil`), `facetOptions(list) → {latime:number[], profil:number[], diametru:number[]}`
  - Cart (`cart` = `[{id, qty}]`, all return new arrays): `cartAdd(cart, product, qty)`, `cartSetQty(cart, id, qty, byId)`, `cartRemove(cart, id)`, `cartNormalize(cart, byId)`, `cartTotals(cart, byId) → {lines:[{product, qty, total}], count, subtotal, hasSh, montajInclusPeTot}`
  - `productLabel(p) → string`, `buildLeadText(title, [[label, value]]) → string`, `buildOrderMessage({id, totals, fields}) → string`
  - `buildSmsHref(phone, body)`, `buildWhatsAppHref(phone, text)`, `buildMailtoHref(email, subject, body)`, `makeOrderId(random?) → "GR-1000".."GR-9999"`
  - `validateLead({nume, telefon}) → {valid, errors:{nume?, telefon?}}`
  - `selfCheckResult(answers, questions) → {complete, level: null|"ok"|"verificare"|"urgent", issues:string[], unsure?:number}`

- [ ] **Step 1: Write the failing tests** — `v1/tests/core.test.js`:

```js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const C = require("../app/js/core.js");

const SCHEDULE = [["09:00", "16:00"], ["08:00", "18:00"], ["08:00", "18:00"], ["08:00", "18:00"], ["08:00", "18:00"], ["08:00", "18:00"], null];

test("formatLei groups thousands with dots", () => {
  assert.equal(C.formatLei(1040), "1.040 lei");
  assert.equal(C.formatLei(95), "95 lei");
  assert.equal(C.formatLei(1234567.4), "1.234.567 lei");
  assert.equal(C.formatLei("abc"), "0 lei");
});

test("escapeHtml neutralises markup", () => {
  assert.equal(C.escapeHtml('<b a="x">&\'</b>'), "&lt;b a=&quot;x&quot;&gt;&amp;&#39;&lt;/b&gt;");
  assert.equal(C.escapeHtml(null), "");
});

test("normalizeText strips Romanian diacritics", () => {
  assert.equal(C.normalizeText("Iarnă Ș ț Î"), "iarna s t i");
});

test("computeOpenStatus: open on a weekday", () => {
  const s = C.computeOpenStatus({ day: 1, minutes: 10 * 60 }, SCHEDULE);
  assert.equal(s.open, true);
  assert.equal(s.label, "Deschis acum · închidem la 18:00");
});

test("computeOpenStatus: last hour shows minutes left", () => {
  const s = C.computeOpenStatus({ day: 2, minutes: 17 * 60 + 20 }, SCHEDULE);
  assert.equal(s.open, true);
  assert.equal(s.minutesLeft, 40);
  assert.equal(s.label, "Deschis acum · închidem la 18:00 (mai ai 40 min)");
});

test("computeOpenStatus: before opening today", () => {
  const s = C.computeOpenStatus({ day: 3, minutes: 7 * 60 }, SCHEDULE);
  assert.equal(s.open, false);
  assert.equal(s.label, "Închis acum · deschidem azi la 08:00");
});

test("computeOpenStatus: after closing on Friday → Sunday (Saturday closed)", () => {
  const s = C.computeOpenStatus({ day: 5, minutes: 19 * 60 }, SCHEDULE);
  assert.equal(s.label, "Închis acum · deschidem duminică la 09:00");
});

test("computeOpenStatus: Saturday → tomorrow (Sunday)", () => {
  const s = C.computeOpenStatus({ day: 6, minutes: 12 * 60 }, SCHEDULE);
  assert.equal(s.label, "Închis acum · deschidem mâine la 09:00");
});

test("computeOpenStatus: closing minute counts as closed", () => {
  const s = C.computeOpenStatus({ day: 0, minutes: 16 * 60 }, SCHEDULE);
  assert.equal(s.open, false);
  assert.equal(s.label, "Închis acum · deschidem mâine la 08:00");
});

test("nowInZone converts to Europe/Bucharest", () => {
  // 2026-10-06T07:30:00Z = 10:30 in Bucharest (UTC+3, summer time), Tuesday
  const n = C.nowInZone(new Date("2026-10-06T07:30:00Z"), "Europe/Bucharest");
  assert.deepEqual(n, { day: 2, minutes: 10 * 60 + 30, month: 9 });
});

test("nowInZone: midnight is minute 0, not 1440", () => {
  const n = C.nowInZone(new Date("2026-01-14T22:00:00Z"), "Europe/Bucharest");
  assert.deepEqual(n, { day: 4, minutes: 0, month: 0 });
});

test("seasonFor: Sep–Feb winter, Mar–Aug summer", () => {
  assert.equal(C.seasonFor(9), "iarna");
  assert.equal(C.seasonFor(0), "iarna");
  assert.equal(C.seasonFor(8), "iarna");
  assert.equal(C.seasonFor(2), "vara");
  assert.equal(C.seasonFor(7), "vara");
});

test("parseSize accepts common notations", () => {
  const exp = { latime: 205, profil: 55, diametru: 16 };
  assert.deepEqual(C.parseSize("205/55 R16"), exp);
  assert.deepEqual(C.parseSize("205/55R16 91V"), exp);
  assert.deepEqual(C.parseSize("205 55 16"), exp);
  assert.deepEqual(C.parseSize("205/55/16"), exp);
  assert.deepEqual(C.parseSize("anvelope 225/45 zr17 iarna"), { latime: 225, profil: 45, diametru: 17 });
});

test("parseSize rejects nonsense", () => {
  assert.equal(C.parseSize(""), null);
  assert.equal(C.parseSize(null), null);
  assert.equal(C.parseSize("0744 398 932"), null);
  assert.equal(C.parseSize("905/55 R16"), null);
});

test("formatSize", () => {
  assert.equal(C.formatSize({ latime: 195, profil: 65, diametru: 15 }), "195/65 R15");
});

const RAW = [
  { id: "a", stare: "nou", sezon: "iarna", brand: "Michelin", model: "Alpin 6", latime: 205, profil: 55, diametru: 16, indice: "91H", pret: 520, stoc: 8, montajInclus: true, recomandat: true },
  { id: "b", stare: "sh", sezon: "iarna", brand: "Continental", model: "WinterContact TS 860", latime: "205", profil: "55", diametru: "16", pret: "180", stoc: "4", profilMm: "6,5", dot: 2021 },
  { id: "c", stare: "nou", sezon: "vara", brand: "Debica", model: "Presto HP2", latime: 195, profil: 65, diametru: 15, pret: 260, stoc: 0 },
  { id: "d", stare: "sh", sezon: "all", brand: "Michelin", model: "CrossClimate 2", latime: 225, profil: 45, diametru: 17, pret: 240, stoc: 2, profilMm: 5 },
  { id: "e", stare: "nou", sezon: "vara", brand: "Hankook", model: "Ventus Prime 3", latime: 205, profil: 55, diametru: 16, pret: 390, stoc: 6, montajInclus: true },
  { id: "bad-price", stare: "nou", sezon: "vara", brand: "X", model: "Y", latime: 205, profil: 55, diametru: 16, stoc: 3 },
  { id: "bad-state", stare: "folosit", sezon: "vara", brand: "X", model: "Y", latime: 205, profil: 55, diametru: 16, pret: 100, stoc: 3 },
  { id: "a", stare: "nou", sezon: "vara", brand: "Dup", model: "Dup", latime: 205, profil: 55, diametru: 16, pret: 100, stoc: 3 },
  null
];
const PRODUCTS = C.sanitizeCatalog(RAW);
const BY_ID = C.indexById(PRODUCTS);

test("sanitizeCatalog coerces strings and drops invalid/duplicate rows", () => {
  assert.deepEqual(PRODUCTS.map(p => p.id), ["a", "b", "c", "d", "e"]);
  const b = BY_ID.b;
  assert.equal(b.latime, 205);
  assert.equal(b.pret, 180);
  assert.equal(b.stoc, 4);
  assert.equal(b.profilMm, 6.5);
  assert.equal(b.dot, 2021);
  assert.equal(b.montajInclus, false);
  assert.equal(BY_ID.a.profilMm, undefined, "profilMm only for SH");
  assert.deepEqual(C.sanitizeCatalog("nope"), []);
});

test("filterProducts by size, season, condition; hides out of stock", () => {
  const ids = (c) => C.filterProducts(PRODUCTS, c).map(p => p.id);
  assert.deepEqual(ids({}), ["a", "b", "d", "e"]);
  assert.deepEqual(ids({ latime: 205, profil: 55, diametru: 16 }), ["a", "b", "e"]);
  assert.deepEqual(ids({ latime: 205, sezon: "iarna" }), ["a", "b"]);
  assert.deepEqual(ids({ stare: "sh" }), ["b", "d"]);
  assert.deepEqual(ids({ sezon: "vara", latime: 195 }), [], "out-of-stock c is hidden");
});

test("filterProducts free-text search ignores case and diacritics", () => {
  const ids = (q) => C.filterProducts(PRODUCTS, { q }).map(p => p.id);
  assert.deepEqual(ids("michelin"), ["a", "d"]);
  assert.deepEqual(ids("CONTINENTAL 205/55"), ["b"]);
  assert.deepEqual(ids("alpin"), ["a"]);
});

test("sortProducts modes", () => {
  const ids = (m) => C.sortProducts(C.filterProducts(PRODUCTS, {}), m).map(p => p.id);
  assert.deepEqual(ids("pret-asc"), ["b", "d", "e", "a"]);
  assert.deepEqual(ids("pret-desc"), ["a", "e", "d", "b"]);
  assert.deepEqual(ids("recomandate"), ["a", "b", "d", "e"]);
  assert.deepEqual(ids("profil"), ["e", "a", "b", "d"]);
  assert.deepEqual(ids("necunoscut"), ids("recomandate"));
});

test("facetOptions lists in-stock sizes, sorted", () => {
  assert.deepEqual(C.facetOptions(PRODUCTS), { latime: [205, 225], profil: [45, 55], diametru: [16, 17] });
});

test("criteriaFromQuery parses known keys and ignores junk", () => {
  assert.deepEqual(
    C.criteriaFromQuery("?latime=175&profil=65&diametru=14&sezon=iarna&stare=sh&q=+michelin+&sort=pret-asc&x=1"),
    { latime: 175, profil: 65, diametru: 14, sezon: "iarna", stare: "sh", q: "michelin", sort: "pret-asc" }
  );
  assert.deepEqual(C.criteriaFromQuery("?latime=&sezon=toamna&stare=folosit&sort=evil"), {});
  assert.deepEqual(C.criteriaFromQuery("?dim=205%2F55R16"), { latime: 205, profil: 55, diametru: 16 });
  assert.deepEqual(C.criteriaFromQuery(""), {});
});

test("queryFromCriteria round-trips and omits defaults", () => {
  const c = { latime: 205, profil: 55, diametru: 16, sezon: "iarna", stare: "", q: "", sort: "recomandate" };
  assert.equal(C.queryFromCriteria(c), "?latime=205&profil=55&diametru=16&sezon=iarna");
  assert.equal(C.queryFromCriteria({}), "");
  assert.deepEqual(C.criteriaFromQuery(C.queryFromCriteria(c)), { latime: 205, profil: 55, diametru: 16, sezon: "iarna" });
});

test("cartAdd merges lines and caps at stock", () => {
  let cart = C.cartAdd([], BY_ID.b, 2);
  assert.deepEqual(cart, [{ id: "b", qty: 2 }]);
  cart = C.cartAdd(cart, BY_ID.b, 5);
  assert.deepEqual(cart, [{ id: "b", qty: 4 }]);
  cart = C.cartAdd(cart, BY_ID.a);
  assert.deepEqual(cart, [{ id: "b", qty: 4 }, { id: "a", qty: 1 }]);
  assert.deepEqual(C.cartAdd(cart, BY_ID.a, 0), cart, "qty 0 is a no-op");
});

test("cartSetQty clamps, removes at zero; cartRemove", () => {
  const cart = [{ id: "a", qty: 1 }, { id: "d", qty: 1 }];
  assert.deepEqual(C.cartSetQty(cart, "d", 9, BY_ID), [{ id: "a", qty: 1 }, { id: "d", qty: 2 }]);
  assert.deepEqual(C.cartSetQty(cart, "a", 0, BY_ID), [{ id: "d", qty: 1 }]);
  assert.deepEqual(C.cartRemove(cart, "a"), [{ id: "d", qty: 1 }]);
});

test("cartNormalize drops unknown/sold-out items, merges duplicates, caps stock", () => {
  const stale = [{ id: "a", qty: 3 }, { id: "zzz", qty: 1 }, { id: "c", qty: 1 }, { id: "a", qty: 7 }, { id: "d", qty: "x" }, null];
  assert.deepEqual(C.cartNormalize(stale, BY_ID), [{ id: "a", qty: 8 }]);
  assert.deepEqual(C.cartNormalize("garbage", BY_ID), []);
});

test("cartTotals sums lines and flags SH / montaj", () => {
  const t = C.cartTotals([{ id: "a", qty: 4 }, { id: "b", qty: 2 }], BY_ID);
  assert.equal(t.count, 6);
  assert.equal(t.subtotal, 4 * 520 + 2 * 180);
  assert.equal(t.hasSh, true);
  assert.equal(t.montajInclusPeTot, false);
  assert.equal(t.lines[0].total, 2080);
  const t2 = C.cartTotals([{ id: "a", qty: 1 }], BY_ID);
  assert.equal(t2.montajInclusPeTot, true);
  assert.equal(C.cartTotals([], BY_ID).montajInclusPeTot, false);
});

test("buildLeadText skips empty fields", () => {
  assert.equal(
    C.buildLeadText("Programare", [["Nume", " Ana "], ["Telefon", "0744"], ["Mesaj", ""], ["Altceva", null]]),
    "Programare\nNume: Ana\nTelefon: 0744"
  );
});

test("buildOrderMessage lists items and totals", () => {
  const totals = C.cartTotals([{ id: "a", qty: 4 }, { id: "b", qty: 2 }], BY_ID);
  const msg = C.buildOrderMessage({ id: "GR-4821", totals, fields: [["Nume", "Ana"], ["Telefon", "0744 111 222"]] });
  assert.equal(msg, [
    "Rezervare GR-4821 - magazin Gumirom",
    "- 4 x Michelin Alpin 6 205/55 R16 91H (nou) - 2.080 lei",
    "- 2 x Continental WinterContact TS 860 205/55 R16 (SH) - 360 lei",
    "Total anvelope: 2.440 lei",
    "Nume: Ana",
    "Telefon: 0744 111 222"
  ].join("\n"));
});

test("link builders encode content", () => {
  assert.equal(C.buildSmsHref("+40744398932", "Salut & pa"), "sms:+40744398932?&body=Salut%20%26%20pa");
  assert.equal(C.buildWhatsAppHref("+40 744 398 932", "Bună"), "https://wa.me/40744398932?text=Bun%C4%83");
  assert.equal(C.buildMailtoHref("a@b.ro", "Subiect", "x y"), "mailto:a@b.ro?subject=Subiect&body=x%20y");
});

test("makeOrderId is GR- plus 4 digits", () => {
  assert.equal(C.makeOrderId(0), "GR-1000");
  assert.equal(C.makeOrderId(0.99999), "GR-9999");
  assert.match(C.makeOrderId(), /^GR-\d{4}$/);
});

test("validateLead accepts Romanian phone formats", () => {
  assert.equal(C.validateLead({ nume: "Ana", telefon: "0744 398 932" }).valid, true);
  assert.equal(C.validateLead({ nume: "Ana", telefon: "+40 744-398-932" }).valid, true);
  const bad = C.validateLead({ nume: " A ", telefon: "123" });
  assert.equal(bad.valid, false);
  assert.ok(bad.errors.nume);
  assert.ok(bad.errors.telefon);
  assert.equal(C.validateLead({ nume: "Ana", telefon: "1".repeat(16) }).valid, false);
});

const QUESTIONS = [
  { id: "uzura", critical: true }, { id: "flanc", critical: true },
  { id: "varsta", critical: false }, { id: "vibratii", critical: false }, { id: "presiune", critical: false }
];

test("selfCheckResult levels", () => {
  const all = (v) => Object.fromEntries(QUESTIONS.map(q => [q.id, v]));
  assert.deepEqual(C.selfCheckResult({ uzura: "nu" }, QUESTIONS).complete, false);
  assert.equal(C.selfCheckResult(all("nu"), QUESTIONS).level, "ok");
  assert.equal(C.selfCheckResult({ ...all("nu"), vibratii: "da" }, QUESTIONS).level, "verificare");
  assert.equal(C.selfCheckResult({ ...all("nu"), varsta: "nustiu" }, QUESTIONS).level, "verificare");
  const urgent = C.selfCheckResult({ ...all("nu"), flanc: "da", presiune: "da" }, QUESTIONS);
  assert.equal(urgent.level, "urgent");
  assert.deepEqual(urgent.issues, ["flanc", "presiune"]);
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd v1 && node --test tests/core.test.js`
Expected: FAIL — `Cannot find module '../app/js/core.js'`.

- [ ] **Step 3: Implement `v1/app/js/core.js`** — UMD wrapper; ES5 style; functions exactly as listed in Interfaces. Key rules:
  - `computeOpenStatus`: open if `opens <= minutes < closes`; append ` (mai ai N min)` when `minutesLeft <= 60`; otherwise search the next 1–7 days for an open day, `mâine` for +1, else day name from `["duminică","luni","marți","miercuri","joi","vineri","sâmbătă"]`.
  - `nowInZone`: `Intl.DateTimeFormat("en-US", {timeZone, weekday:"short", hour:"2-digit", minute:"2-digit", month:"numeric", hourCycle:"h23"}).formatToParts`; `hour % 24`; fallback to local `Date` getters in `catch`.
  - `parseSize`: regex `/(\d{3})\s*[\/\s-]?\s*(\d{2})\s*[\/\s-]?\s*Z?R?F?\s*(\d{2})(?!\d)/i`; accept width 125–355, profile 25–85, rim 12–24.
  - `sanitizeProduct`: numbers via `parseFloat(String(v).replace(",", "."))`; require `stare ∈ {nou, sh}`, `sezon ∈ {vara, iarna, all}`, non-empty `brand`, positive `latime/profil/diametru/pret`; `stoc = max(0, floor(n) || 0)`; `montajInclus/recomandat` only when `=== true`; `profilMm` (>0) and `dot` (>1990) only for SH.
  - `filterProducts`: hides `stoc <= 0`; `sezon/stare` empty or `"toate"` mean all; `q` split on whitespace, every word must appear in normalised `brand model size indice`.
  - `sortProducts`: `recomandate` = recommended first then price asc; `profil` = new tyres first (tread 100) then `profilMm` desc, tie → price asc; unknown mode → `recomandate`.
  - `criteriaFromQuery`: ints 1–399 for sizes; `dim` param parsed with `parseSize`; `q` trimmed, max 60 chars; `sort` must be a known mode.
  - `queryFromCriteria`: keys in order `latime, profil, diametru, sezon, stare, q, sort`; omit empty values and `sort === "recomandate"`.
  - `buildOrderMessage`: ASCII separators (` - `, ` x `) to keep SMS short; title `Rezervare <id> - magazin Gumirom`; lines `- <qty> x <productLabel> - <formatLei(total)>`; `Total anvelope: …`; then `fields` via `buildLeadText`.
  - `validateLead`: name ≥ 2 trimmed chars; phone 9–15 digits after stripping non-digits; messages `Te rugăm să introduci numele tău.` / `Introdu un număr de telefon valid (ex. 0744 398 932).`
  - `selfCheckResult`: incomplete → `{complete:false, level:null}`; any critical `da` → `urgent`; any `da` or `nustiu` → `verificare`; else `ok`.

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd v1 && node --test tests/core.test.js`
Expected: all tests PASS, 0 failures.

---

### Task 2: Business config + lead delivery (`config.js`, `lead.js`)

**Files:**
- Create: `v1/app/js/config.js`, `v1/app/js/lead.js`
- Modify: `v1/app/css/style.css` (append `.lead-panel` styles)

**Interfaces:**
- Consumes: `GumiromCore.buildLeadText`, `buildSmsHref`, `buildWhatsAppHref`, `buildMailtoHref`.
- Produces:
  - `window.GUMIROM_CONFIG = { telefon:"+40744398932", telefonAfisat:"0744 398 932", formspreeId:"", whatsapp:false, email:"", rezervareZile:3, fusOrar:"Europe/Bucharest", program:[["09:00","16:00"],["08:00","18:00"],["08:00","18:00"],["08:00","18:00"],["08:00","18:00"],["08:00","18:00"],null] }` (comments mark `whatsapp`, `rezervareZile` as `DE CONFIRMAT`).
  - `window.GumiromLead.isConfigured() → boolean`
  - `window.GumiromLead.send({ title, fields, status }) → Promise<{channel:"formspree"|"fallback"}>` — `status` is the element where success/fallback UI is rendered. Formspree POST JSON `{_subject: title, mesaj: text, ...fields}` with `Accept: application/json`; non-OK/network error → fallback panel with an error line.
  - `window.GumiromLead.renderFallback(status, title, text, intro?)` — builds (with `createElement`, never `innerHTML` of user data) a `.lead-panel`: intro line, buttons `Trimite prin SMS` (always), `Trimite pe WhatsApp` (if `whatsapp`), `Trimite e-mail` (if `email`), `Sună: 0744 398 932`, and `Copiază mesajul` (Clipboard API, fallback to selecting a readonly `<textarea>` with the text). Sets focus to the panel heading for screen readers.
  - `window.GumiromLead.whatHappensNext() → string` — channel-specific "Ce urmează" sentence used by forms.

- [ ] **Step 1:** Write `config.js` with the object above and owner instructions in comments (how to get a Formspree ID, when to set `whatsapp: true`).
- [ ] **Step 2:** Write `lead.js` (IIFE, `"use strict"`) implementing the contract.
- [ ] **Step 3:** Append `.lead-panel` styles (white card inside dark sections, buttons wrap, readable error/success colours).
- [ ] **Step 4:** Verify: `node --test tests/core.test.js` still passes; manual check deferred to Task 6.

---

### Task 3: Global fixes — shared CSS/JS behaviour + optimised images

**Files:**
- Modify: `v1/app/css/style.css`, `v1/app/js/main.js` (rewrite of shared parts), `v1/app/index.html` (head: inline `js` class script, `<picture>`)
- Create: `v1/app/assets/hero.webp`, `hero-1280.webp`, `anvelope.webp`, `anvelope-800.webp`, `despre.webp`, `despre-800.webp`

**Interfaces:**
- Consumes: `GumiromCore.computeOpenStatus`, `nowInZone`, `seasonFor`; `GUMIROM_CONFIG.program/fusOrar`.
- Produces (DOM contracts used by both pages):
  - `<html class="js">` set by inline head script `document.documentElement.classList.add("js")`; `.reveal` hides only under `.js`.
  - `[hidden] { display: none !important; }`.
  - Header: `body.menu-open` raises `.site-header` above `.mobile-menu` (z-index 130), transparent background, light hamburger bars and brand text.
  - Mobile menu: `setMenu(open)` cancels pending hide timeout, moves focus to first link on open and back to `.nav-toggle` on close, sets `inert` on `main`, `footer`, `.sticky-bar`, `.back-to-top`; closes on `Escape`, link click, and `matchMedia("(min-width: 900px)")` change.
  - Scroll: one `requestAnimationFrame`-throttled handler updates header shadow, back-to-top (`visibility` hidden when not shown) and scrollspy (only `.nav-link[href^="#"]`; last link active at page bottom).
  - `[data-open-status]` elements get `computeOpenStatus` label; sibling `.status-dot` gets `.is-closed` when closed. Rows `[data-days="1-5"|"6"|"0"]` get `.is-today`.
  - `[data-winter][data-summer]` text set by `seasonFor(month)`.
  - `[data-track]` click → `window.dataLayer.push({event:"cta_click", cta:<value>})` only if `dataLayer` exists.
  - `#an-curent` ← current year.

- [ ] **Step 1:** Generate WebP with Pillow (quality 78): hero 1920 + 1280 wide; anvelope/despre 1400 + 800 wide.
- [ ] **Step 2:** CSS fixes: `[hidden]`, `.js .reveal`, `.back-to-top` visibility, `body.menu-open` header, field-error specificity (`.contact-form .field-error`, `.contact-form .form-status`), `.split-media` shadow on container, sticky bar spacing.
- [ ] **Step 3:** Rewrite shared parts of `main.js` per contract (keep accordion; remove carousel).
- [ ] **Step 4:** Verify in browser (Task 6 checklist items 1–6).

---

### Task 4: Homepage restructure (Sell Like Crazy 17 steps + Hormozi offer)

**Files:**
- Modify: `v1/app/index.html` (full body rewrite), `v1/app/css/style.css` (new section styles), `v1/app/js/main.js` (homepage blocks)

**Interfaces:**
- Consumes: Task 2 `GumiromLead.send/whatHappensNext`, Task 1 `validateLead`, `selfCheckResult`, `buildLeadText`.
- Produces (DOM):
  - Sections in order with ids: `#acasa` hero, trustbar, `#probleme`, `#servicii`, `#cum-lucram`, `#oferta`, `#garantii`, `#anvelope` (contains `form.size-finder[action="magazin.html"][method="get"]` with selects `latime/profil/diametru`), `#test` (`[data-quiz]`, questions `fieldset[data-q][data-critical]` with radios `da/nu/nustiu`, result `[data-quiz-result]`), `#despre`, `#recenzii` (`.reviews-grid`), `#intrebari`, `#contact` (`#formular-contact`), `.ps-close`, footer, `.sticky-bar`.
  - Copy as specified in spec §4 (headline: „Roți schimbate în 30–45 de minute — fără cozi, fără vibrații, fără surprize la plată.”).
  - Contact form submit: `validateLead` → errors via `aria-invalid` + `aria-describedby` → `GumiromLead.send({title:"Programare - " + serviciu, fields:[["Nume",…],["Telefon",…],["Serviciu",…],["Mesaj",…]], status})`; submit button disabled while sending; on Formspree success `form.reset()`.
  - Quiz: on every change compute `selfCheckResult`; when complete show verdict text per level + CTA `tel:` + button "Trimite rezultatul prin SMS" (uses `GumiromLead.renderFallback` with the result text).
  - JSON-LD: add `image`, `areaServed`, `paymentAccepted: "Cash"`.

- [ ] **Step 1:** Rewrite `index.html` body in the section order above; fix typos (`vindem`, `Treci pe la noi`); mark new commitments with `<!-- DE CONFIRMAT -->`; reviews flagged `<!-- DE VERIFICAT: înlocuiește cu recenzii reale, cu acord -->`.
- [ ] **Step 2:** Add CSS for `.problem-grid`, `.steps`, `.offer`, `.stack`, `.guarantee-grid`, `.size-finder`, `.quiz`, `.reviews-grid`, `.ps-close`, `.sticky-bar`, `.hero-bullets`.
- [ ] **Step 3:** Add homepage JS blocks (contact form, quiz).
- [ ] **Step 4:** `node --test tests/core.test.js` passes; browser checklist (Task 6) items 7–11.

---

### Task 5: Online shop MVP (`magazin.html`, `shop.css`, `catalog.js`, `shop.js`)

**Files:**
- Create: `v1/app/magazin.html`, `v1/app/css/shop.css`, `v1/app/js/catalog.js`, `v1/app/js/shop.js`

**Interfaces:**
- Consumes: Task 1 (`sanitizeCatalog`, `indexById`, `criteriaFromQuery`, `queryFromCriteria`, `filterProducts`, `sortProducts`, `facetOptions`, cart functions, `cartTotals`, `buildOrderMessage`, `makeOrderId`, `formatLei`, `formatSize`, `escapeHtml`, `validateLead`, `productLabel`), Task 2 (`GumiromLead`), Task 3 shared DOM.
- Produces:
  - `window.GUMIROM_CATALOG = { demo: true, actualizat: "2026-10-06", produse: [...] }` — ~16 demo products (new + SH, all seasons, common sizes 185/65 R15, 195/65 R15, 205/55 R16, 215/60 R16, 225/45 R17, 225/65 R17) with owner instructions in comments.
  - DOM ids: `#shop-filters` (selects `latime/profil/diametru/sort`, radio groups `sezon/stare`, input `q`), `#shop-count`, `#product-grid`, `#shop-empty`, `#demo-banner`, `#form-oferta`, cart: `#cart` (`role="dialog" aria-modal="true"`), `#cart-lines`, `#cart-total`, `#form-rezervare`, `#cart-confirm`, buttons `[data-cart-open]` with `[data-cart-count]` badges.
  - Storage wrapper: `load()`/`save()` around `localStorage["gumirom_cart_v1"]` in `try/catch`; on failure keep in-memory cart.
  - Behaviour: URL ↔ filters (`history.replaceState`); size from URL missing in facets is added as an option; empty state button "Cere ofertă pentru <size>" prefills `#form-oferta` and scrolls to it; cards show „Ultimele N bucăți” only when `stoc <= 2`; add-to-cart opens drawer; drawer traps focus, `Escape` closes, focus returns to opener; reservation submit → `validateLead` → `GumiromLead.send({title:"Rezervare " + id, …})` with text from `buildOrderMessage`; on Formspree success or after showing fallback panel the order summary + code is shown; cart cleared only after Formspree success or when the visitor presses "Am trimis rezervarea" in fallback mode.

- [ ] **Step 1:** `catalog.js` with demo data.
- [ ] **Step 2:** `magazin.html` (shared header/footer/mobile menu with links to `index.html#…`, `aria-current="page"` on Magazin).
- [ ] **Step 3:** `shop.css`.
- [ ] **Step 4:** `shop.js`.
- [ ] **Step 5:** `node --test tests/core.test.js` passes; browser checklist items 12–18.

---

### Task 6: Verification and review

**Files:** none created (fixes go to the files above).

- [ ] **Step 1:** `cd v1 && node --test tests/core.test.js` → all pass.
- [ ] **Step 2:** `node --check` on every `app/js/*.js`.
- [ ] **Step 3:** Serve `v1/app` with `python -m http.server 8765` and check in the browser (desktop 1280px + mobile 375px), console free of errors:
  1. No content invisible; reveal animates.
  2. Back-to-top not clickable/focusable until scrolled 600px.
  3. Mobile menu: X visible over overlay, focus moves in, Escape closes and restores focus, background inert.
  4. Header shadow updates while scrolling; scrollspy marks Contact at the bottom.
  5. Open status label correct for current Bucharest time.
  6. Images load as WebP.
  7. Season text shows „Iarnă fără griji” in October.
  8. Contact form: empty submit shows red errors; valid submit shows fallback panel with SMS/Call/Copy.
  9. Quiz: verdict appears after 5 answers; levels correct.
  10. Size finder opens `magazin.html?latime=…&profil=…&diametru=…`.
  11. Sticky bar visible on mobile only; footer not covered.
  12. Shop: demo banner visible; products render; filters + URL sync.
  13. Deep link with missing size → empty state + prefilled „Cere ofertă”.
  14. Add to cart, change qty, remove; badge counts update; reload keeps cart.
  15. Cart dialog: focus trap, Escape, focus return.
  16. Reservation: validation; fallback panel; confirmation code.
  17. „Ultimele N bucăți” only for stock ≤ 2.
  18. Mobile layout of filters and cards, no horizontal scroll.
- [ ] **Step 4:** Fresh reviewer subagent reviews the whole change set against spec + plan; fix confirmed findings; re-run Steps 1–3.
