openvibers/openvibe.shoes Commits
Expand Shoes product catalog and guides (#100)
Changes
@@@ -40,6 +40,10 @@ pasted Marketplace link is handed to Watch, which applies its own fetch rules.| `/track` | paste a listing link; it becomes a Watch || `/watches` | a signed-in person's watches (private, no-store) || `/developers` | the API, sources and examples |++| `/laces` · `/lace-length` | lacing diagrams and a source-cited length estimate from measured eyelet spacing |++| `/socks` | brand-specific size conversion and use/material guidance |++| `/care` | maker-cited care notes by material, with unknown compatibility stated clearly |++| `/kids` · `/kids/measure` | manufacturer charts, a calibrated printable ruler, and an opt-in private Today remeasure reminder || `/api/openapi.json` | the OpenAPI 3.1 document |All pages are server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js`, `kit.card`,@@@ -55,6 +59,10 @@ Everything under `/api/v1`; errors are `application/problem+json` with a `code`.| `GET /api/v1/shoes?q&brand&size&condition&store&sort&page&limit` | `{ shoes, facets, page, per_page, total, sort, filters }`; sorts `recent`, `drop`, `price`, `price_desc`, `popular`, `name` || `GET /api/v1/shoes/:slug` | `{ shoe, offers, history, curve, facts, drops }` || `GET /api/v1/shoes/:slug/price?size=&condition=` | `{ slug, size, condition, price, currency, checked_at, offers }` — the URL a Watch polls |++| `GET /api/v1/products?kind=&q=&size=` | shared catalog envelope with kinds `shoe`, `laces`, `socks`, `care`, `insoles`, `kids` (`shoes.product@1`) |++| `GET /api/v1/products/:id` · `/offers` | one cited product or its offer list |++| `GET /api/v1/tools/lace-length?eyelet_pairs=&method=&horizontal_cm=&vertical_cm=&ends_cm=` | measured lace estimate in cm/inches using a cited formula |++| `GET /api/v1/sizes/convert?brand=darn-tough&system=men\|women\|kids&size=` | brand-chart conversion to sock size || `GET /api/v1/drops?from&to` | `{ from, to, drops }`, soonest first || `GET /api/v1/drops/:id` | one drop || `GET /api/v1/me/watches` | session only: the person's watches |nnew file mode 100644@@@ -0,0 +1,53 @@++-- phase: expand++-- The shared product catalog can grow beyond shoe_models without changing the established shoe/offer tables.++-- Accessory rows are published only after a cited, permitted source is configured.++CREATE TABLE shoe_products (++ id text COLLATE "C" PRIMARY KEY,++ kind text COLLATE "C" NOT NULL,++ slug text COLLATE "C" NOT NULL UNIQUE,++ title text COLLATE "C" NOT NULL,++ brand text COLLATE "C",++ model text COLLATE "C",++ url text,++ image text,++ sizes jsonb NOT NULL DEFAULT '[]'::jsonb,++ materials jsonb NOT NULL DEFAULT '[]'::jsonb,++ uses jsonb NOT NULL DEFAULT '[]'::jsonb,++ source_name text COLLATE "C" NOT NULL,++ source_url text NOT NULL,++ checked_at timestamptz NOT NULL,++ facts jsonb NOT NULL DEFAULT '[]'::jsonb,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_products_kind CHECK (kind IN ('shoe', 'laces', 'socks', 'care', 'insoles', 'kids')),++ CONSTRAINT shoe_products_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 160),++ CONSTRAINT shoe_products_source_url CHECK (source_url ~ '^https?://'),++ CONSTRAINT shoe_products_url CHECK (url IS NULL OR url ~ '^https?://'),++ CONSTRAINT shoe_products_image CHECK (image IS NULL OR image ~ '^https?://')++);++CREATE INDEX shoe_products_kind_title ON shoe_products (kind, lower(title));++CREATE INDEX shoe_products_checked ON shoe_products (checked_at DESC);++++CREATE TABLE shoe_product_offers (++ id text COLLATE "C" PRIMARY KEY,++ product_id text COLLATE "C" NOT NULL REFERENCES shoe_products (id) ON DELETE CASCADE,++ source text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ store_domain text COLLATE "C" NOT NULL,++ store_name text,++ title text COLLATE "C" NOT NULL,++ url text NOT NULL,++ price_amount numeric(10,2),++ shipping_amount numeric(10,2),++ currency text COLLATE "C" NOT NULL DEFAULT 'USD',++ size text COLLATE "C",++ condition text COLLATE "C",++ checked_at timestamptz NOT NULL,++ stale boolean NOT NULL DEFAULT false,++ raw jsonb NOT NULL DEFAULT '{}'::jsonb,++ CONSTRAINT shoe_product_offers_unique UNIQUE (source, source_id),++ CONSTRAINT shoe_product_offers_url CHECK (url ~ '^https?://'),++ CONSTRAINT shoe_product_offers_price CHECK (price_amount IS NULL OR price_amount >= 0),++ CONSTRAINT shoe_product_offers_shipping CHECK (shipping_amount IS NULL OR shipping_amount >= 0)++);++CREATE INDEX shoe_product_offers_product ON shoe_product_offers (product_id, checked_at DESC);@@@ -135,3 +135,5 @@.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }}++.sh-exp{max-width:900px;margin:0 auto;padding:clamp(18px,4vw,48px);color:var(--text-primary,#17191f)}.sh-exp h1{max-width:18ch;font-size:clamp(2rem,5vw,3.4rem);line-height:1.04;letter-spacing:-.04em}.sh-exp h2{margin-top:2.4rem}.sh-exp p,.sh-exp li{max-width:72ch;line-height:1.65}.sh-exp a{color:var(--accent-strong,#b02a2d)}.sh-exp-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:14px;margin:24px 0;padding:20px;border:1px solid var(--border,rgba(0,0,0,.16));border-radius:18px;background:var(--bg-secondary,#f7f5f2)}.sh-exp-form label{display:grid;gap:6px;font-weight:650}.sh-exp-form input,.sh-exp-form select{min-height:46px;max-width:100%;padding:8px 12px;border:1px solid var(--border,#aaa);border-radius:10px;background:var(--bg-primary,#fff);color:inherit;font:inherit}.sh-exp-form button,.sh-exp .sh-btn{min-height:46px;align-self:end}.sh-exp-consent{grid-column:1/-1;display:flex!important;align-items:flex-start}.sh-exp-consent input{flex:none;min-width:22px;min-height:22px}.sh-exp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px}.sh-exp-grid article,.sh-exp-result{padding:18px;border:1px solid var(--border,rgba(0,0,0,.16));border-radius:16px;background:var(--bg-secondary,#f7f5f2)}.sh-lace-diagram{width:120px;height:90px;float:right}.sh-lace-diagram path{fill:none;stroke:var(--accent-strong,#b02a2d);stroke-width:3}.sh-lace-diagram circle{fill:var(--text-primary,#17191f)}.sh-exp details{padding:12px 0;border-bottom:1px solid var(--border,rgba(0,0,0,.16))}.sh-exp summary{min-height:44px;display:flex;align-items:center;font-weight:700;cursor:pointer}.sh-ruler{display:block;width:min(100%,290mm);height:20mm;margin:24px auto;overflow:visible}.sh-ruler path{fill:none;stroke:currentColor;stroke-width:1}.sh-ruler text{font-size:12px;fill:currentColor}.sh-exp-notice{padding:12px;background:var(--bg-secondary,#f7f5f2);border-radius:10px}@media(prefers-color-scheme:dark){.sh-exp{color:var(--text-primary,#f5f4f1)}}@page{size:A4 landscape;margin:5mm}@media print{body *{visibility:hidden!important}.sh-print,.sh-print *{visibility:visible!important}.sh-print{position:absolute;inset:0;width:100%;max-width:none;margin:0;padding:0;color:#000!important}.sh-print .sh-btn{display:none!important}.sh-ruler{width:290mm;height:20mm}}++.sh-home-categories{max-width:1180px;margin:12px auto 28px;padding:0 16px}.sh-home-categories h2{margin:8px 0 12px;font-size:clamp(20px,3vw,28px)}.sh-home-categories>div{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.sh-home-categories>div>a{position:relative;display:grid;align-content:center;gap:5px;min-height:78px;padding:12px 34px 12px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:15px;background:var(--bg-secondary,#111827);color:var(--text-primary,#eef2fb);text-decoration:none}.sh-home-categories>div>a:hover{border-color:var(--room-accent,#ff7e76);transform:translateY(-1px)}.sh-home-categories b{font-size:15px}.sh-home-categories span{font-size:12px;line-height:1.35;color:var(--text-secondary,#9aa8bd)}.sh-home-categories i{position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:20px;font-style:normal;color:var(--room-accent,#ff7e76)}.sh-home-categories>p{margin:12px 0}.sh-home-categories>p>a{display:inline-flex;align-items:center;min-height:44px;padding:0 10px;color:var(--room-accent,#ff7e76)}.sh-ruler-calibration{stroke:var(--room-accent,#ff7e76)!important;stroke-width:4!important}@media(max-width:760px){.sh-home-categories>div{grid-template-columns:repeat(2,minmax(0,1fr))}.sh-home-categories>div>a{min-height:74px}.sh-home-categories>div>a span{font-size:12px}}nnew file mode 100644@@@ -0,0 +1,73 @@++# Open.Shoes expansion research++++Research date: 2026-11-09. Public pages were reviewed without accounts. Competitor features below describe the product’s public positioning, not a full usability audit.++++## Market and competitor map++++| Segment | Examples | What they are strong at | Product opportunity for Open.Shoes |++|---|---|---|---|++| General shoe retail | [Zappos](https://www.zappos.com/), [Foot Locker](https://www.footlocker.com/) | Broad retail inventory, brand and size navigation, straightforward purchase availability | Aggregate all-in prices and size stock across sources, with a model history and source timestamps |++| Sneaker resale | [StockX](https://stockx.com/), [GOAT](https://www.goat.com/) | Authentication/resale context and model/colourway discovery | Show new retail alongside resale only when source and condition are explicit; avoid implying authentication |++| Deal discovery | [Sole Retriever](https://www.soleretriever.com/), [Kicks Deals](https://www.kicksdeals.com/) | Release/deal discovery and sneaker-focused editorial | Combine official release links with price alerts and unambiguous all-in offer rows |++| Running research and specialty retail | [RunRepeat](https://runrepeat.com/), [Running Warehouse](https://www.runningwarehouse.com/), [Running Shoe Deals methodology](https://bestrunningshoedeals.com/methodology) | RunRepeat emphasizes testing/reviews; Running Warehouse has specialist product information; comparison sites show the value of exact model matching and size-aware price comparison | Add cited fit/spec facts and model-level size-price history; do not claim fit advice without a cited source |++| Kids | [Stride Rite sizing help](https://www.striderite.com/pages/sizing-help), [See Kai Run](https://seekairun.com/), [Pediped](https://pediped.com/), [Nike Kids](https://www.nike.com/w/kids-shoes-v4dhzy7ok), [Adidas Kids](https://www.adidas.com/us/kids-shoes) | Brand charts, printable measuring resources, stage/age-oriented navigation; Stride Rite explicitly says age is not a reliable shoe-size proxy beyond babies | Keep manufacturer-specific foot-length charts, label age as broad guidance, provide a print-and-measure page and opt-in check-in reminders |++| Socks | [Bombas](https://bombas.com/), [Darn Tough](https://darntough.com/), [Stance](https://www.stance.com/), [Balega](https://balega.com/) | Brand-specific sizing and clear use/material positioning (running, hiking, everyday, compression) | Cross-brand size lookup from shoe size with each brand chart cited; filter by use and material |++| Laces | [Lace Lab](https://www.lacelab.com/), [Kiwi](https://www.kiwishoecare.com/), [Ian’s Shoelace Site](https://www.fieggen.com/shoelace/) | Lace styles/lengths and practical lacing instructions | Eyelet-pair + method calculator; link the right length/type to each shoe only when known |++| Shoe care | [Jason Markk](https://jasonmarkk.com/), [Crep Protect](https://crepprotect.com/), [Reshoevn8r](https://reshoevn8r.com/) | Material-oriented cleaning kits and product education | Material guide (leather, suede, knit, canvas, mesh, nubuck) with product compatibility and citations |++++The category expansion is commercially coherent: shoes bring intent, while socks, laces, care and insoles are adjacent replenishment or maintenance needs. Kids adds a distinct fit/measurement use case and recurring size checks. Treat revenue/market size as unquantified here; this pass is a competitor and source map, not a financial forecast.++++## Source and access assessment++++| Source | Access | Suitable use | Gate / caveat |++|---|---|---|---|++| OpenVibe.Deals | Existing public network feed; no key | Initial offers across supported product kinds | Confirm Deals’ current footwear/accessory classification and terms before expanding ingestion |++| eBay Browse API | Official API, existing Shoes client | Search/offer summaries for items | Owner must provision developer key pair; existing code already gates this source |++| Brand/store public Shopify storefront catalog (`/products.json`) | Sometimes public without auth | Product metadata and variants for participating DTC brands | Per-store permission/terms and request limits need review; public reachability is not blanket reuse permission; no price ingestion until terms are checked |++| Manufacturer size/care charts | Public pages/PDFs | Cited shoe/sock size conversion, kids foot-length tables, care compatibility | Preserve chart owner, URL, access date, units, region and model applicability; never infer one brand’s sizing from another |++| Open product-data datasets | No broadly established apparel equivalent confirmed in this search | Potential identifiers/material metadata | Candidate source only; licensing and coverage require separate review |++| Marketplace feeds and retailer affiliate APIs | Account/key-dependent | Wider retail coverage | Owner gate: no account creation or spend; do not enable until owner supplies credentials and approves terms |++++Do not scrape Facebook Marketplace, automate raffles/draws, add affiliate links, or treat public HTML/catalog endpoints as permission by themselves. Missing credentials should continue to disable only that source with a plain status note.++++## Modular product/API proposal++++Use one typed product record for `shoe|laces|socks|care|insoles|kids`, with stable id, kind, brand, line/model, title, URL, image, variant/size fields, material/use tags, cited facts, source identity and check time. Offers should retain the network’s shared offer shape and source provenance. A product without a verified attribute should return null/unknown and render “not known yet.”++++Proposed public endpoints from the brief: `GET /api/v1/products?kind=&q=&size=`, `GET /api/v1/products/:id/offers`, `GET /api/v1/tools/lace-length`, and `GET /api/v1/sizes/convert`. Keep the existing `/shoes` routes stable while this schema is introduced. Put `shoes.product@1` in Contracts and `ov.shoes.*` in SDK as separate library PRs; add the reusable `offerCard()` to Shared only if no compatible shared card exists. Deals can then consume Shoes’ public product search without sharing Shoes-specific persistence.++++The kid-size reminder should store only a nickname and current size, require explicit opt-in, and schedule 3-month toddler / 6-month child check-ins through Today. Do not collect date of birth. Brand growth charts are guidance, not a guarantee that age predicts size.++++## Evidence checked++++- Stride Rite sizing help, accessed 2026-11-09: https://www.striderite.com/pages/sizing-help — states age is not a reliable size proxy except for babies and links fit guidance.++- Stride Rite FAQ, accessed 2026-11-09: https://www.striderite.com/pages/stride-rite-faq — describes home measurement and printable chart options.++- Running Shoe Deals methodology, accessed 2026-11-09: https://bestrunningshoedeals.com/methodology — describes matching models across 44 stores and size-specific price tracking; useful competitive benchmark.++- Ian’s Shoelace Site, accessed 2026-11-09: https://www.fieggen.com/shoelace/ — primary practical reference for lacing patterns and lace length guidance; cite the exact method/table used in the eventual calculator.++- eBay Browse API overview, accessed 2026-11-09: https://developer.ebay.com/api-docs/buy/browse/overview.html — official API documentation; credentials remain an owner gate.++- Shopify storefront docs, accessed 2026-11-09: https://shopify.dev/docs/api/storefront — official documentation; use a supported Storefront API with owner-provided access rather than assuming an exposed catalog endpoint is licensed.++++## Limitations++++This quick web pass confirms named competitors and primary sizing/API references, but does not establish market size, retailer catalog reuse rights, or a durable apparel-wide open dataset. Those need source-by-source legal/coverage checks before automated ingestion. Requested 390 px and 1280 px UI screenshots belong with the implemented pages and should be generated from the running local service after the UI changes.++++## Deeper source check and what the build adds++++Additional primary sources checked 2026-11-09:++++- [Ian Fieggen’s shoelace formulas](https://www.fieggen.com/shoelace/lengthformulas.htm) expose measured eyelet count, across-shoe spacing, row spacing and tie-end length; the estimator uses the Criss Cross and Bow Tie/Army formulas and returns the assumptions.++- [Darn Tough’s current sock size chart](https://darntough.com/pages/size-chart) maps distinct US men’s, women’s and kids’ shoe-size ranges to its own sock letters and advises sizing up between categories. The API conversion is limited to this brand chart; it does not claim a universal sock size.++- [Stride Rite’s kids sizing guide](https://www.striderite.com/pages/sizing-help) gives foot lengths and some baby month labels, while saying age is not a dependable proxy after baby sizes. The page shows three explicit chart examples and links the full current table.++- [Clarks’ shoe care guide](https://www.clarks.com/en-us/editorial/how-to-clean-shoes) distinguishes canvas/fabric from leather and suede. [Stuart Weitzman’s guide](https://www.stuartweitzman.com/content/sw-care-guide) cautions against some suede brushes and recommends professional care for some leather cleaning. This disagreement is why Shoes links to the maker and avoids one-method-fits-all advice.++- [Open Products Facts’ dataset entry](https://www.data.gouv.fr/datasets/open-products-facts) identifies the ODbL. It is a plausible later identifier/material source, but share-alike obligations and apparel coverage need evaluation before importing it. It was not needed to ship the no-cost public Deals feed integration.++- Public user discussions are anecdotal, not care authorities: [SneakerRestoration cleaning discussion](https://www.reddit.com/r/SneakerRestoration/comments/1q790so/how_to_clean_them/) and [running shoe cleaning discussion](https://www.reddit.com/r/AskRunningShoeGeeks/comments/1ruwpup/how_do_you_clean_your_running_shoes/) show conflicting preferences about hand-cleaning, machine washing and whether to clean at all. The product therefore uses manufacturer instructions for advice.++++### Three useful additions beyond the brief++++1. **A calibration step for the printable ruler.** The page includes a 10 cm check on a 28 cm ruler and says to discard the measurement if printing scaled it. It also asks the parent to measure both feet and use the larger length. This prevents a mis-scaled printout or the smaller foot from silently producing a false size. Built at `/kids/measure`.++2. **A material-first risk check before recommending cleaners.** Model pages lead with that model’s cited care notes; the guide says to follow the most delicate material and not assume machine washing is safe. It says product compatibility is unknown until a maker confirms it. Built at `/care` and linked from every shoe model page.++3. **Show the chart and source boundary on every conversion.** Sock conversion reports its chart URL and only answers for Darn Tough’s brand-specific ranges. Between-brand size translation stays unknown. Built in `GET /api/v1/sizes/convert`.++++## UI evidence++++Screenshots are committed in this PR under `research-2026-11-09/shoes/screenshots/`: home and printable measuring page, each at 390 px and 1280 px in dark and light browser color schemes. The local service was loaded from the development PGlite store; no production data was written.nnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-light.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-light.png differ@@@ -34,6 +34,7 @@ const { openapi } = require('./http/openapi');const { createServiceReadiness } = require('./observability');const { assetVersion, setRelease } = require('./render/layout');const { createCatalogue } = require('./catalogue');++const { createProductStore } = require('./product-store');const { createUpstreams } = require('./upstreams');const { createJobs } = require('./jobs');const { createSearchIndex } = require('./search-index');@@@ -62,8 +63,9 @@ async function createApp(opts = {}) {// The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours, sharing });++ const products = createProductStore(s);const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });-- const jobs = createJobs({ config, catalogue, upstreams, log });++ const jobs = createJobs({ config, catalogue, products, upstreams, log });// The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).const searchIndex = createSearchIndex({ config, s, log });@@@ -74,7 +76,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, sharing, accountData, accountSend, log };++ const ctx = { config, s, keys, sso, principal, catalogue, products, upstreams, jobs, searchIndex, sharing, accountData, accountSend, log };const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.@@@ -22,13 +22,14 @@ const express = require('express');const { http } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { openapi } = require('./openapi');++const productCatalog = require('../product-catalog');const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);const CONDITIONS = new Set(['new', 'used', 'deadstock']);function createApi(ctx) {-- const { config, catalogue, principal, upstreams } = ctx;++ const { config, catalogue, products: productStore, principal, upstreams } = ctx;const r = asyncRouter();r.use(express.json({ limit: '64kb' }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });@@@ -95,6 +96,57 @@ function createApi(ctx) {return res.json({ slug, size, condition, price: out.price, currency: 'USD', checked_at: out.checked_at, offers: out.offers });});++ // One catalog envelope for every product kind. Until a cited and permitted feed is configured, accessories++ // correctly return an empty result with a plain source note instead of invented products or prices.++ r.get('/products', async (req, res) => {++ const kind = str(req.query.kind, 20) || null;++ if (kind && !productCatalog.KINDS.includes(kind)) return fail(res, req, 400, 'product.kind_invalid', 'kind must be shoe, laces, socks, care, insoles or kids.');++ const q = str(req.query.q, 80).toLowerCase();++ const size = str(req.query.size, 20);++ const models = kind && kind !== 'shoe' ? [] : await catalogue.listShoes({ q: q || null, size: size || null, limit: 100, offset: 0 });++ const shoeProducts = models.filter((m) => !q || `${m.brand} ${m.model}`.toLowerCase().includes(q)).map((m) => productCatalog.asProduct(m));++ const otherProducts = kind === 'shoe' ? [] : await productStore.list({ kind, q: q || null, size: size || null, limit: 100, offset: 0 });++ const resultProducts = [...shoeProducts, ...otherProducts].slice(0, 100);++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ products: resultProducts, total: resultProducts.length, kind, source_note: kind && resultProducts.length === 0 ? 'No cited, permitted product catalog is available for this kind yet; no product or offer data is being inferred.' : null });++ });++++ r.get('/products/:id', async (req, res) => {++ const slug = str(req.params.id, 160);++ const stored = await productStore.bySlug(slug);++ if (stored) return res.json({ product: stored });++ const model = await catalogue.modelBySlug(slug);++ if (!model) return fail(res, req, 404, 'product.not_found', 'No product with that id.');++ return res.json({ product: productCatalog.asProduct(model, await catalogue.factsOf(model.id)) });++ });++++ r.get('/products/:id/offers', async (req, res) => {++ const id = str(req.params.id, 160);++ const stored = await productStore.bySlug(id);++ if (stored) return res.json({ product_id: stored.id, offers: await productStore.offers(id) });++ const model = await catalogue.modelBySlug(id);++ if (!model) return fail(res, req, 404, 'product.not_found', 'No product with that id.');++ const offers = await catalogue.offersOf(model.id);++ return res.json({ product_id: model.slug, offers });++ });++++ r.get('/tools/lace-length', (req, res) => {++ const result = productCatalog.laceLength({++ eyeletPairs: Number(req.query.eyelet_pairs), method: str(req.query.method, 30) || 'criss-cross',++ horizontalCm: req.query.horizontal_cm === undefined ? 3.5 : Number(req.query.horizontal_cm),++ verticalCm: req.query.vertical_cm === undefined ? 2.5 : Number(req.query.vertical_cm),++ endsCm: req.query.ends_cm === undefined ? 25 : Number(req.query.ends_cm),++ });++ if (!result) return fail(res, req, 400, 'lace.input_invalid', 'Use 2–16 eyelet pairs, criss-cross or bow-tie, and valid spacing/end measurements in cm.');++ return res.json(result);++ });++++ r.get('/sizes/convert', (req, res) => {++ const result = productCatalog.sizeConvert({ system: str(req.query.system, 20), size: str(req.query.size, 20), brand: str(req.query.brand, 40) || 'darn-tough' });++ if (!result) return fail(res, req, 400, 'size.input_invalid', 'Provide a Darn Tough shoe-size system (men, women or kids) and a numeric shoe size.');++ return res.json(result);++ });++r.get('/drops', async (req, res) => {const from = req.query.from ? new Date(String(req.query.from)) : new Date(Date.now() - 3600_000);const to = req.query.to ? new Date(String(req.query.to)) : null;@@@ -57,6 +57,12 @@ function publicPages(models = []) {{ path: '/', changefreq: 'hourly', priority: 1.0, title: 'Open.Shoes home', text: 'Every shoe, every market, the right price: all-in prices, the price history and the size-price curve.' },{ path: '/shoes', changefreq: 'hourly', priority: 0.9, title: 'Every shoe', text: 'Search every model by brand, size and condition; every offer is all-in with its store and the time it was checked.' },{ path: '/drops', changefreq: 'daily', priority: 0.8, title: 'The drops calendar', text: 'Official shoe release dates, each cited to the brand\'s or store\'s own release page, with a reminder on OpenVibe.Today.' },++ { path: '/laces', changefreq: 'monthly', priority: 0.7, title: 'Laces and lacing guides', text: 'Measured shoelace length estimates, sourced formulas and illustrated lacing guides.' },++ { path: '/lace-length', changefreq: 'monthly', priority: 0.7, title: 'Lace length finder', text: 'Estimate length from eyelet count, measured spacing and lacing method.' },++ { path: '/socks', changefreq: 'monthly', priority: 0.7, title: 'Sock sizing by shoe size', text: 'Brand-specific sock size conversion and use/material guidance.' },++ { path: '/care', changefreq: 'monthly', priority: 0.7, title: 'Shoe care by material', text: 'Manufacturer-cited care guidance for shoe upper materials.' },++ { path: '/kids', changefreq: 'monthly', priority: 0.7, title: 'Baby and kids shoe fit guide', text: 'Foot measurement, manufacturer size charts and an optional remeasure reminder.' },++ { path: '/kids/measure', changefreq: 'monthly', priority: 0.6, title: 'Printable foot ruler', text: 'A calibration-checked printable 10 cm ruler and how-to guide.' },{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The Shoes API', text: 'The open JSON API for shoes, offers, history, curves, drops and watches, and the OpenAPI document.' },];for (const m of models) {@@@ -102,6 +108,9 @@ function createDiscoveryRoutes(ctx) {{ title: 'Search shoes', url: `${base()}/api/v1/shoes?q=air+max` },{ title: 'One shoe', url: `${base()}/api/v1/shoes/nike-air-max-90` },{ title: 'Drops', url: `${base()}/api/v1/drops` },++ { title: 'Products', url: `${base()}/api/v1/products?kind=shoe` },++ { title: 'Lace length tool', url: `${base()}/api/v1/tools/lace-length?eyelet_pairs=6` },++ { title: 'Size conversion', url: `${base()}/api/v1/sizes/convert?brand=darn-tough&system=women&size=8` },{ title: 'OpenAPI', url: `${base()}/api/openapi.json` },] },],@@@ -19,6 +19,7 @@ function openapi(config = {}) {slug: { name: 'slug', in: 'path', required: true, schema: { type: 'string', maxLength: 80 } },from: { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } },to: { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } },++ kind: { name: 'kind', in: 'query', schema: { type: 'string', enum: ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids'] } },};const ok = (schema) => ({ 200: { description: 'OK', content: { 'application/json': { schema } } } });const bad = { 400: { description: 'Bad request', content: { 'application/problem+json': { schema: problem } } }, 404: { description: 'Not found', content: { 'application/problem+json': { schema: problem } } } };@@@ -32,8 +33,23 @@ function openapi(config = {}) {license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },},servers: [{ url: base }, { url: 'https://open.shoes' }],-- tags: [{ name: 'shoes' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],++ tags: [{ name: 'shoes' }, { name: 'products' }, { name: 'tools' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],paths: {++ '/api/v1/products': { get: { tags: ['products'], summary: 'Search the shared product catalog', operationId: 'listProducts', parameters: [params.kind, params.q, params.size], responses: { ...ok({ type: 'object', properties: { products: { type: 'array', items: { type: 'object' } }, total: { type: 'integer' }, source_note: { type: ['string', 'null'] } } }), ...bad } } },++ '/api/v1/products/{id}': { get: { tags: ['products'], summary: 'Get one product with cited facts', operationId: 'getProduct', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { ...ok({ type: 'object', properties: { product: { type: 'object' } } }), ...bad } } },++ '/api/v1/products/{id}/offers': { get: { tags: ['products'], summary: 'Get offers for one product', operationId: 'getProductOffers', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { ...ok({ type: 'object', properties: { product_id: { type: 'string' }, offers: { type: 'array', items: { type: 'object' } } } }), ...bad } } },++ '/api/v1/tools/lace-length': { get: { tags: ['tools'], summary: 'Estimate lace length from eyelet layout and lacing method', operationId: 'laceLength', parameters: [++ { name: 'eyelet_pairs', in: 'query', required: true, schema: { type: 'integer', minimum: 2, maximum: 16 } },++ { name: 'method', in: 'query', schema: { type: 'string', enum: ['criss-cross', 'bow-tie'] } },++ { name: 'horizontal_cm', in: 'query', schema: { type: 'number', minimum: 0.5, maximum: 12, default: 3.5 } },++ { name: 'vertical_cm', in: 'query', schema: { type: 'number', minimum: 0.5, maximum: 12, default: 2.5 } },++ { name: 'ends_cm', in: 'query', schema: { type: 'number', minimum: 5, maximum: 80, default: 25 } },++ ], responses: { ...ok({ type: 'object', properties: { cm: { type: 'number' }, inches: { type: 'number' }, method: { type: 'string' }, source: { type: 'object' } } }), ...bad } } },++ '/api/v1/sizes/convert': { get: { tags: ['products'], summary: 'Convert a supported brand shoe size to its sock size', operationId: 'convertSize', parameters: [++ { name: 'brand', in: 'query', schema: { type: 'string', enum: ['darn-tough'], default: 'darn-tough' } },++ { name: 'system', in: 'query', required: true, schema: { type: 'string', enum: ['men', 'women', 'kids'] } },++ { name: 'size', in: 'query', required: true, schema: { type: 'number' } },++ ], responses: { ...ok({ type: 'object', properties: { conversions: { type: 'array', items: { type: 'object' } }, source: { type: 'object' } } }), ...bad } } },'/api/v1/shoes': {get: {tags: ['shoes'], summary: 'Search the catalogue', operationId: 'listShoes',@@@ -21,6 +21,7 @@ const { sameOrigin } = require('./principal');const { send } = require('../render/layout');const views = require('../views');const discovery = require('./discovery');++const productCatalog = require('../product-catalog');// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.@@@ -94,6 +95,51 @@ function createPageRoutes(ctx) {});});++ const expansionFaq = (name, text) => [{ '@type': 'FAQPage', mainEntity: [{ '@type': 'Question', name, acceptedAnswer: { '@type': 'Answer', text } }] }];++ r.get('/products', (req, res) => res.redirect(302, `/api/v1/products${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));++ for (const [route, kind, title, description] of [++ ['/laces', 'laces', 'Shoelaces and lacing guides', 'Estimate lace length from eyelet spacing and lacing method, then follow simple lacing diagrams.'],++ ['/socks', 'socks', 'Sock sizing by shoe size', 'Use the sock maker’s size chart for the correct shoe-size range, material and use.'],++ ['/care', 'care', 'Shoe care by upper material', 'Manufacturer-sourced shoe-care notes for leather, suede, knit, canvas, mesh and nubuck.'],++ ['/kids', 'kids', 'Baby and kids shoe fit guide', 'Measure growing feet, consult brand charts and set an optional remeasure reminder.'],++ ]) r.get(route, (req, res) => page(req, res, {++ title, description, body: views.expansionPage({ kind, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }), active: route,++ index: true, jsonLd: expansionFaq(`How do I use ${title.toLowerCase()}?`, description),++ }));++++ r.get('/lace-length', (req, res) => {++ const result = req.query.eyelet_pairs ? productCatalog.laceLength({++ eyeletPairs: Number(req.query.eyelet_pairs), method: String(req.query.method || 'criss-cross'),++ horizontalCm: req.query.horizontal_cm === undefined ? 3.5 : Number(req.query.horizontal_cm),++ verticalCm: req.query.vertical_cm === undefined ? 2.5 : Number(req.query.vertical_cm),++ endsCm: req.query.ends_cm === undefined ? 25 : Number(req.query.ends_cm),++ }) : null;++ return page(req, res, { title: 'Lace length finder', description: 'Estimate lace length using eyelet count, spacing and method.', body: views.expansionPage({ kind: 'laces', result }), active: '/laces', index: true,++ jsonLd: [{ '@type': 'HowTo', name: 'Estimate shoelace length', step: ['Count eyelet pairs on one shoe.', 'Measure horizontal and vertical spacing in centimeters.', 'Choose a lacing pattern and tie-end length.', 'Compare the rounded estimate with your current lace.'].map((text) => ({ '@type': 'HowToStep', text })) }, ...expansionFaq('Is this lace length exact?', 'No. It is an estimate from your measurements; compare it with a lace that fits your shoe.') ] });++ });++++ r.get('/kids/measure', (req, res) => page(req, res, { title: 'Printable foot measuring ruler', description: 'Print at actual size, verify the 10 cm calibration line, and measure each foot.', body: views.expansionPage({ kind: 'measure' }), active: '/kids', index: true,++ jsonLd: [{ '@type': 'HowTo', name: 'Measure a child’s foot at home', step: ['Print at 100% / Actual Size.', 'Verify the 10 cm calibration mark.', 'Measure the longest toe while the child is standing.', 'Use the larger foot measurement with the chosen brand’s chart.'].map((text) => ({ '@type': 'HowToStep', text })) }] }));++++ r.post('/kids/growth-check', async (req, res) => {++ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fkids');++ if (!guardOrigin(req, res)) return;++ const nickname = typeof req.body.nickname === 'string' ? req.body.nickname.trim().slice(0, 40) : '';++ const size = typeof req.body.size === 'string' ? req.body.size.trim().slice(0, 16) : '';++ const stage = req.body.stage === 'toddler' ? 'toddler' : req.body.stage === 'child' ? 'child' : '';++ if (req.body.consent !== 'yes' || !size || !stage) return res.redirect(302, `/kids?notice=${encodeURIComponent('Choose a current size and stage, then opt in to add a reminder.')}`);++ const due = new Date();++ due.setUTCDate(1);++ due.setUTCMonth(due.getUTCMonth() + (stage === 'toddler' ? 3 : 6));++ const days = new Date(Date.UTC(due.getUTCFullYear(), due.getUTCMonth() + 1, 0)).getUTCDate();++ due.setUTCDate(Math.min(new Date().getUTCDate(), days));++ due.setUTCHours(9, 0, 0, 0);++ const display = nickname ? `${nickname}: ` : '';++ const out = await upstreams.createTodayEvent({ kind: 'event', title: `Remeasure ${display}kids’ feet`.slice(0, 120), description: `Current shoe size: ${size}. Recheck both feet and compare with the shoe maker’s chart. This reminder contains no birthday.`, starts_at: due.toISOString(), tz: 'UTC', visibility: 'private' }, { token: req.viewer.token });++ const notice = out.ok ? `Private reminder added for ${due.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}.` : `Today could not add the reminder: ${out.detail}`;++ return res.redirect(302, `/kids?notice=${encodeURIComponent(notice)}`);++ });++r.get('/shoe/:slug', async (req, res) => {const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);@@@ -8,8 +8,8 @@ const { createOffersJob } = require('./offers');const { createDropsJob } = require('./drops');const { createEbayJob } = require('./ebay');--function createJobs({ config, catalogue, upstreams, log = console }) {-- const offers = createOffersJob({ config, catalogue, upstreams, log });++function createJobs({ config, catalogue, products, upstreams, log = console }) {++ const offers = createOffersJob({ config, catalogue, products, upstreams, log });const drops = createDropsJob({ config, catalogue, upstreams, log });const ebay = createEbayJob({ config, catalogue, upstreams, log });const all = [offers, drops, ebay];@@@ -12,7 +12,7 @@*/const parse = require('../domain/parse');--function createOffersJob({ config, catalogue, upstreams, log = console }) {++function createOffersJob({ config, catalogue, products, upstreams, log = console }) {let timer = null;let running = false;@@@ -21,14 +21,24 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {return parse.isShoe(offer && offer.title, productName);}++ function productKind(offer) {++ const text = `${offer && offer.title || ''} ${offer && offer.product && offer.product.name || ''}`.toLowerCase();++ if (/\b(shoelaces?|laces)\b/.test(text)) return 'laces';++ if (/\b(socks?|compression socks?)\b/.test(text)) return 'socks';++ if (/\b(insoles?|orthotic inserts?)\b/.test(text)) return 'insoles';++ if (/\b(shoe care|shoe cleaner|sneaker cleaner|shoe brush|cleaning kit|shoe protector|waterproofing spray)\b/.test(text)) return 'care';++ if (shoeCandidate(offer)) return /\b(baby|toddler|kids?|children['’]?s)\b/.test(text) ? 'kids' : 'shoe';++ return null;++ }++async function runOnce({ pages = config.sources.offersPages, maxProductLookups = config.sources.productLookups } = {}) {const runId = await catalogue.startJobRun({job: 'offers',sourceName: 'OpenVibe.Deals',sourceUrl: `${config.sources.dealsUrl}/api/v1/offers`,});-- const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, skipped: 0 };-- const brands = new Map();++ const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, products: 0, accessory_offers: 0, skipped: 0 };++ const productsSeen = new Map();try {const total = Math.max(1, Math.min(200, Number(pages) || 1));for (let page = 1; page <= total; page++) {@@@ -37,22 +47,44 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {counts.pages++;counts.seen += offers.length;for (const offer of offers) {-- if (!shoeCandidate(offer)) continue;++ const kind = productKind(offer);++ if (!kind) continue;counts.candidates++;const observation = offer.latest_observation || {};const product = offer.product || {};-- let brand = null;++ let sourceProduct = null;if (product.slug && counts.product_lookups < maxProductLookups) {-- if (brands.has(product.slug)) brand = brands.get(product.slug);++ if (productsSeen.has(product.slug)) sourceProduct = productsSeen.get(product.slug);else {const p = await upstreams.dealsProduct(product.slug);counts.product_lookups++;-- brand = p && p.brand ? p.brand : null;-- brands.set(product.slug, brand);++ sourceProduct = p || null;++ productsSeen.set(product.slug, sourceProduct);}}-- const modelName = product.name || offer.title;++ const brand = sourceProduct && sourceProduct.brand ? sourceProduct.brand : null;++ const modelName = sourceProduct && sourceProduct.name || product.name || offer.title;const checkedAt = observation.observed_at || observation.recorded_at || new Date().toISOString();++ if (kind !== 'shoe') {++ if (!products || !product.slug || !sourceProduct || !offer.link && !offer.url) { counts.skipped++; continue; }++ const slug = String(product.slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 150);++ const catalogProduct = await products.upsert({++ kind, slug, title: modelName, brand, model: modelName, url: `https://openvibe.deals/products/${encodeURIComponent(product.slug)}`,++ sizes: [parse.sizeOf(offer.title)].filter(Boolean), sourceName: 'OpenVibe.Deals',++ sourceUrl: `https://openvibe.deals/api/v1/products/${encodeURIComponent(product.slug)}`, checkedAt, facts: [],++ });++ const item = await products.upsertOffer({++ productSlug: catalogProduct.id, source: 'deals', sourceId: offer.id, storeDomain: offer.store && offer.store.domain,++ storeName: offer.store && offer.store.name, title: offer.title, url: offer.link || offer.url,++ price: observation.price, shipping: observation.shipping, currency: observation.currency || 'USD',++ size: parse.sizeOf(offer.title), condition: observation.condition, checkedAt, raw: { id: offer.id, product: product.slug },++ });++ if (catalogProduct) counts.products++;++ if (item) counts.accessory_offers++;++ else counts.skipped++;++ if (config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs / 2);++ continue;++ }const row = await catalogue.upsertOffer({source: 'deals',sourceId: offer.id,@@@ -82,7 +114,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {if (page < total && config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs);}await catalogue.finishJobRun(runId, { ok: true, counts });-- log.log(`[Open.Shoes] offers: ${counts.offers} offers from ${counts.candidates} shoe candidates over ${counts.pages} pages`);++ log.log(`[Open.Shoes] offers: ${counts.offers} shoe offers and ${counts.accessory_offers} product offers from ${counts.candidates} candidates over ${counts.pages} pages`);return counts;} catch (err) {await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });@@@ -107,7 +139,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {timer = null;}-- return { name: 'offers', runOnce, start, stop, shoeCandidate };++ return { name: 'offers', runOnce, start, stop, shoeCandidate, productKind };}module.exports = { createOffersJob };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++const KINDS = ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids'];++const CATEGORIES = [++ { kind: 'shoe', title: 'Shoes', href: '/shoes', description: 'Compare all-in prices by model and size.' },++ { kind: 'laces', title: 'Laces', href: '/laces', description: 'Find a starting length and explore lacing methods.' },++ { kind: 'socks', title: 'Socks', href: '/socks', description: 'Use each sock brand’s own shoe-size chart.' },++ { kind: 'care', title: 'Shoe care', href: '/care', description: 'Care notes by upper material, with manufacturer sources.' },++ { kind: 'insoles', title: 'Insoles', href: '/products?kind=insoles', description: 'Compatibility and prices will appear when verified sources are available.' },++ { kind: 'kids', title: 'Kids & baby', href: '/kids', description: 'Measure first, then compare manufacturer size charts.' },++];++++const METHODS = {++ 'criss-cross': 'Criss Cross',++ 'bow-tie': 'Bow Tie / Army',++};++++function laceLength({ eyeletPairs, method = 'criss-cross', horizontalCm = 3.5, verticalCm = 2.5, endsCm = 25 } = {}) {++ const p = Number(eyeletPairs), h = Number(horizontalCm), v = Number(verticalCm), l = Number(endsCm);++ if (!Number.isInteger(p) || p < 2 || p > 16 || !Number.isFinite(h) || h < 0.5 || h > 12 || !Number.isFinite(v) || v < 0.5 || v > 12 || !Number.isFinite(l) || l < 5 || l > 80) return null;++ let cm;++ if (method === 'criss-cross') cm = 2 * (h + (p - 1) * Math.hypot(h, v) + l);++ else if (method === 'bow-tie') cm = 2 * (h + v * Math.floor(p / 2) + Math.hypot(h, v) * Math.floor((p - 1) / 2) + l);++ else return null;++ return { method, method_name: METHODS[method], eyelet_pairs: p, cm: Math.ceil(cm / 5) * 5, inches: Math.ceil((cm / 2.54) / 2) * 2, assumptions: { horizontal_cm: h, vertical_cm: v, ends_cm: l }, source: { name: "Ian's Shoelace Site", url: 'https://www.fieggen.com/shoelace/lengthformulas.htm' } };++}++++const SOCK_CHARTS = {++ men: [ [3, 5, 'S'], [5.5, 7.5, 'M'], [8, 9.5, 'L'], [10, 12, 'XL'] ],++ women: [ [4.5, 7, 'S'], [7.5, 9.5, 'M'], [10, 11.5, 'L'], [12, 14, 'XL'] ],++ kids: [ [9, 11.5, 'S'], [12, 14.5, 'M'], [3, 6, 'L'] ],++};++++function sizeConvert({ system, size, brand = 'darn-tough' } = {}) {++ const value = Number(size);++ if (!Number.isFinite(value) || !system) return null;++ if (brand === 'darn-tough' && ['men', 'women', 'kids'].includes(system)) {++ let range;++ if (system === 'kids') {++ const label = value >= 9 && value <= 11.5 ? 'S' : ([12, 13, 1, 1.5, 2, 2.5].includes(value) ? 'M' : value >= 3 && value <= 6 ? 'L' : null);++ range = label ? [0, 0, label] : null;++ } else range = SOCK_CHARTS[system].find(([min, max]) => value >= min && value <= max);++ return { brand, system, input_size: String(size), conversions: range ? [{ system: 'sock', size: range[2] }] : [], source: { name: 'Darn Tough sock size chart', url: 'https://darntough.com/pages/size-chart' } };++ }++ return null;++}++++function asProduct(model, facts = []) {++ return {++ id: model.slug, kind: 'shoe', title: `${model.brand} ${model.model}`, brand: model.brand, model: model.model,++ url: `/shoe/${encodeURIComponent(model.slug)}`, image: null, sizes: [], materials: [], uses: [],++ source: { name: 'OpenVibe.Deals', url: 'https://openvibe.deals' }, checked_at: model.first_seen || new Date(0).toISOString(),++ facts: facts.map((fact) => ({ name: fact.label, value: fact.value, source: { name: fact.source_name, url: fact.source_url } })),++ };++}++++module.exports = { KINDS, CATEGORIES, laceLength, sizeConvert, asProduct };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++const { KINDS } = require('./product-catalog');++++function json(value, fallback = []) {++ if (Array.isArray(value)) return value;++ if (typeof value === 'string') { try { const out = JSON.parse(value); return Array.isArray(out) ? out : fallback; } catch { return fallback; } }++ return fallback;++}++++function wireProduct(row) {++ return {++ id: row.slug, kind: row.kind, title: row.title, brand: row.brand || null, model: row.model || null,++ url: row.url || null, image: row.image || null, sizes: json(row.sizes), materials: json(row.materials), uses: json(row.uses),++ source: { name: row.source_name, url: row.source_url }, checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : String(row.checked_at), facts: json(row.facts),++ };++}++++function wireOffer(row) {++ const price = row.price_amount == null ? null : Number(row.price_amount);++ const shipping = row.shipping_amount == null ? null : Number(row.shipping_amount);++ return {++ id: row.id, product_id: row.product_slug, store: row.store_domain, store_name: row.store_name || null,++ title: row.title, url: row.url, price, shipping, shipping_known: shipping !== null,++ all_in: price == null ? null : Math.round((price + (shipping || 0)) * 100) / 100,++ currency: row.currency, size: row.size || null, condition: row.condition || null,++ checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : String(row.checked_at), stale: row.stale === true, source: row.source,++ };++}++++function createProductStore(store) {++ const { db } = store;++ return {++ async list({ kind = null, q = null, size = null, limit = 100, offset = 0 } = {}) {++ const rows = await db.many(`SELECT * FROM shoe_products++ WHERE ($1::text IS NULL OR kind = $1)++ AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' OR brand ILIKE '%' || $2 || '%')++ AND ($3::text IS NULL OR sizes ? $3)++ ORDER BY lower(title), slug LIMIT $4 OFFSET $5`, [kind, q, size, limit, offset]);++ return rows.map(wireProduct);++ },++ async bySlug(slug) {++ const row = await db.maybe('SELECT * FROM shoe_products WHERE slug = $1 LIMIT 1', [slug]);++ return row ? wireProduct(row) : null;++ },++ async offers(slug) {++ const rows = await db.many(`SELECT o.*, p.slug AS product_slug FROM shoe_product_offers o++ JOIN shoe_products p ON p.id = o.product_id WHERE p.slug = $1 ORDER BY o.checked_at DESC`, [slug]);++ return rows.map(wireOffer);++ },++ async upsert(input) {++ if (!input || !KINDS.includes(input.kind) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(input.slug || ''))++ || !input.title || !input.sourceName || !/^https?:\/\//.test(String(input.sourceUrl || ''))) throw new TypeError('product kind, slug, title and cited source are required');++ const id = `sp_${input.slug}`;++ await db.exec(`INSERT INTO shoe_products (id, kind, slug, title, brand, model, url, image, sizes, materials, uses, source_name, source_url, checked_at, facts)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb)++ ON CONFLICT (slug) DO UPDATE SET kind=EXCLUDED.kind,title=EXCLUDED.title,brand=EXCLUDED.brand,model=EXCLUDED.model,++ url=EXCLUDED.url,image=EXCLUDED.image,sizes=EXCLUDED.sizes,materials=EXCLUDED.materials,uses=EXCLUDED.uses,++ source_name=EXCLUDED.source_name,source_url=EXCLUDED.source_url,checked_at=EXCLUDED.checked_at,facts=EXCLUDED.facts,updated_at=now()`,++ [id, input.kind, input.slug, input.title, input.brand || null, input.model || null, input.url || null, input.image || null,++ JSON.stringify(input.sizes || []), JSON.stringify(input.materials || []), JSON.stringify(input.uses || []), input.sourceName, input.sourceUrl,++ input.checkedAt || store.iso(), JSON.stringify(input.facts || [])]);++ return this.bySlug(input.slug);++ },++ async upsertOffer(input) {++ if (!input || !input.productSlug || !input.source || !input.sourceId || !/^https?:\/\//.test(String(input.url || ''))) throw new TypeError('product, source id and http(s) offer URL are required');++ const product = await db.maybe('SELECT id FROM shoe_products WHERE slug=$1', [input.productSlug]);++ if (!product) throw new Error('product not found');++ const id = store.newId('spo');++ const saved = await db.one(`INSERT INTO shoe_product_offers (id,product_id,source,source_id,store_domain,store_name,title,url,price_amount,shipping_amount,currency,size,condition,checked_at,stale,raw)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)++ ON CONFLICT (source,source_id) DO UPDATE SET store_domain=EXCLUDED.store_domain,store_name=EXCLUDED.store_name,title=EXCLUDED.title,url=EXCLUDED.url,++ price_amount=EXCLUDED.price_amount,shipping_amount=EXCLUDED.shipping_amount,currency=EXCLUDED.currency,size=EXCLUDED.size,condition=EXCLUDED.condition,checked_at=EXCLUDED.checked_at,stale=EXCLUDED.stale,raw=EXCLUDED.raw RETURNING id`,++ [id, product.id, input.source, input.sourceId, input.storeDomain, input.storeName || null, input.title, input.url, input.price ?? null, input.shipping ?? null,++ input.currency || 'USD', input.size || null, input.condition || null, input.checkedAt || store.iso(), Boolean(input.stale), JSON.stringify(input.raw || {})]);++ return (await this.offers(input.productSlug)).find((offer) => offer.id === saved.id) || null;++ },++ };++}++++module.exports = { createProductStore, wireProduct, wireOffer };@@@ -246,6 +246,10 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curveexplore: {title: 'More on Shoes',items: [++ { title: 'Laces & length finder', text: 'Measure spacing and choose a lacing method for an estimate in cm and inches', href: '/laces', icon: 'ov:link' },++ { title: 'Socks by shoe size', text: 'Check a brand’s own chart, with use and material guidance', href: '/socks', icon: 'ov:shoe' },++ { title: 'Care by material', text: 'Manufacturer-sourced notes for leather, suede, knit, canvas, mesh and nubuck', href: '/care', icon: 'ov:heart' },++ { title: 'Kids & baby fit', text: 'Measure their feet, open a printable ruler and set a private growth reminder', href: '/kids', icon: 'ov:shoe' },{ title: 'Every shoe', text: 'Search by brand, model, size and condition; every price is all-in', href: '/shoes', icon: 'ov:search' },{ title: 'The drops calendar', text: 'Official release dates, each cited to the brand\'s or store\'s own page', href: '/drops', icon: 'ov:calendar' },{ title: 'Your watches', text: 'The price watches and pasted links that OpenVibe.Watch carries', href: '/watches', icon: 'ov:bell' },@@@ -283,16 +287,40 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curve],},});-- // The price-drop section is a real rail when there are drops and an honest note when there are none; it sits-- // directly under the hero, before the "dropping this week" and "popular models" rails.-- const railAt = body.indexOf('<section class="ovk-rail');-- const sceneAt = body.indexOf('<section class="sc-room-scenes"');-- const at = railAt >= 0 ? railAt : sceneAt;++ // Category tiles follow the hero; the price-drop rail precedes the other model/release rails.++ const categories = `<section class="sh-home-categories" aria-labelledby="sh-home-categories-h"><h2 id="sh-home-categories-h">More than shoes</h2><div>${[++ ['Laces', 'Length finder and lacing guides', '/laces'], ['Socks', 'Brand size charts by shoe size', '/socks'],++ ['Care', 'Guides by upper material', '/care'], ['Kids & baby', 'Measure, chart and remeasure', '/kids'],++ // Insoles come back as a tile when they have product matches (a tile that led to an empty JSON list was a dead end).++ ].map(([title, line, href]) => `<a href="${href}"><b>${title}</b><span>${line}</span><i aria-hidden="true">→</i></a>`).join('')}</div><p><a href="/lace-length">Lace length tool</a><span aria-hidden="true"> · </span><a href="/kids/measure">Print the foot ruler</a></p></section>`;++ const heroEnd = body.indexOf('</section>');++ const homeWithCategories = heroEnd >= 0 ? `${body.slice(0, heroEnd + 10)}${categories}${body.slice(heroEnd + 10)}` : `${categories}${body}`;// The home sits in the page's width like every other page (it ran edge to edge, 10-31).-- const page = at < 0 ? `${body}${priceDropSection}` : `${body.slice(0, at)}${priceDropSection}${body.slice(at)}`;++ const railAtIn = homeWithCategories.indexOf('<section class="ovk-rail');++ const sceneAtIn = homeWithCategories.indexOf('<section class="sc-room-scenes"');++ const homeAt = railAtIn >= 0 ? railAtIn : sceneAtIn;++ const page = homeAt < 0 ? `${homeWithCategories}${priceDropSection}` : `${homeWithCategories.slice(0, homeAt)}${priceDropSection}${homeWithCategories.slice(homeAt)}`;return `<div class="sh-wrap sh-home">${page}</div>`;}++function laceDiagram(name, reverse = false) {++ const path = reverse ? 'M20 15 L100 55 M20 55 L100 15 M20 35 L100 35' : 'M20 15 L100 55 M100 15 L20 55 M20 35 L100 75 M100 35 L20 75';++ return `<svg class="sh-lace-diagram" viewBox="0 0 120 90" role="img" aria-label="${esc(name)} lacing diagram"><path d="${path}"/><circle cx="20" cy="15" r="3"/><circle cx="100" cy="15" r="3"/><circle cx="20" cy="35" r="3"/><circle cx="100" cy="35" r="3"/><circle cx="20" cy="55" r="3"/><circle cx="100" cy="55" r="3"/><circle cx="20" cy="75" r="3"/><circle cx="100" cy="75" r="3"/></svg>`;++}++++function expansionPage({ kind, result, notice, signedIn = false } = {}) {++ const escLink = (href, label) => `<a href="${esc(href)}" rel="noopener">${esc(label)}</a>`;++ if (kind === 'laces') return `<main class="sh-exp"><p class="sh-kicker">Laces · tools</p><h1>Find a lace length that fits your lacing</h1><p>Eyelet count is a starting point. Measure across the shoe, between eyelet rows, and allow for the ends you want to tie. Different methods use different amounts of lace.</p><form class="sh-exp-form" action="/lace-length" method="get"><label>Eyelet pairs <input type="number" min="2" max="16" name="eyelet_pairs" value="${esc(result?.eyelet_pairs || 6)}" required></label><label>Lacing method <select name="method"><option value="criss-cross"${result?.method === 'criss-cross' ? ' selected' : ''}>Criss-cross</option><option value="bow-tie"${result?.method === 'bow-tie' ? ' selected' : ''}>Bow-tie / Army</option></select></label><label>Across tongue (cm) <input type="number" min="0.5" max="12" step="0.1" name="horizontal_cm" value="${esc(result?.assumptions?.horizontal_cm || 3.5)}"></label><label>Between rows (cm) <input type="number" min="0.5" max="12" step="0.1" name="vertical_cm" value="${esc(result?.assumptions?.vertical_cm || 2.5)}"></label><label>Each tie end (cm) <input type="number" min="5" max="80" step="1" name="ends_cm" value="${esc(result?.assumptions?.ends_cm || 25)}"></label><button class="sh-btn" type="submit">Estimate length</button></form>${result ? `<section class="sh-exp-result" aria-live="polite"><h2>Starting estimate</h2><p><strong>${result.cm} cm</strong> · ${result.inches} in · ${result.eyelet_pairs} eyelet pairs · ${esc(result.method_name)}</p><p>Rounded up for common retail lengths. Compare with a lace that fits now; width, eyelet position and knot preference change the result.</p></section>` : ''}<h2>Two lacing methods</h2><div class="sh-exp-grid"><article>${laceDiagram('Criss-cross lacing')}<h3>Criss-cross</h3><p>Alternating diagonals distribute tension and use a standard crossed path.</p></article><article>${laceDiagram('Bow-tie lacing', true)}<h3>Bow-tie / Army</h3><p>Straight across the shoe, with vertical sections between rows.</p></article></div><p>Formula reference: ${escLink('https://www.fieggen.com/shoelace/lengthformulas.htm', "Ian's Shoelace Site length formulas")}. Estimates use your five measurements and are not a guaranteed fit.</p><h2>Frequently asked</h2><details><summary>Can eyelet count alone tell me the right length?</summary><p>No. Width, row spacing, lacing pattern and knot ends change the length.</p></details><details><summary>Does Open.Shoes sell or recommend a lace?</summary><p>We show offers only when the source identifies them. No verified lace products are configured yet.</p></details></main>`;++ if (kind === 'socks') return `<main class="sh-exp"><p class="sh-kicker">Socks · size and use</p><h1>Start with your shoe size. Check the sock brand’s chart.</h1><p>Sock sizes vary by brand and intended fit. Darn Tough recommends using shoe size and sizing up between categories. We keep this conversion brand-specific.</p><form class="sh-exp-form" action="/api/v1/sizes/convert" method="get" target="_blank"><label>Chart <select name="brand"><option value="darn-tough">Darn Tough</option></select></label><label>Shoe size system <select name="system"><option value="men">US men</option><option value="women">US women</option><option value="kids">US kids</option></select></label><label>Shoe size <input name="size" type="number" step="0.5" min="1" max="20" required></label><button class="sh-btn">Find sock size</button></form><p>Source: ${escLink('https://darntough.com/pages/size-chart', 'Darn Tough sock sizing chart')} (checked 2026-11-09). Other sock brands: Bombas, Stance, Balega and Smartwool charts are not yet encoded; follow each manufacturer’s chart.</p><h2>Choose by use</h2><ul><li>Running: look for the brand’s running-specific cushioning and fit notes.</li><li>Hiking: compare crew height, cushioning and merino/fiber details.</li><li>Everyday: compare the maker’s everyday height and material options.</li><li>Compression: use the maker’s circumference chart; shoe size alone is not enough.</li></ul><p>Materials to compare: merino wool, synthetic blends, cotton and nylon. We don’t guess a product’s fiber content.</p><h2>Frequently asked</h2><details><summary>Are sock sizes the same as shoe sizes?</summary><p>No. A brand maps a range of shoe sizes to its own sock sizes.</p></details><details><summary>What if I am between two sock sizes?</summary><p>Check the manufacturer’s own recommendation. Darn Tough recommends sizing up.</p></details></main>`;++ if (kind === 'care') return `<main class="sh-exp"><p class="sh-kicker">Shoe care · material guide</p><h1>Care for the upper material first</h1><p>Check the maker’s label and model page before cleaning. Mixed materials need the most delicate method present.</p><div class="sh-exp-grid"><article><h2>Leather</h2><p>Start with a soft dry cloth; for deeper cleaning, follow the shoe maker’s leather instructions. Product compatibility: not known yet.</p></article><article><h2>Suede & nubuck</h2><p>Brush dry and gently; avoid soaking. A maker may recommend a specific brush or professional cleaning. Product compatibility: not known yet.</p></article><article><h2>Knit & mesh</h2><p>Remove loose dirt before spot-cleaning. Check the exact model’s care label before using water or a cleaner.</p></article><article><h2>Canvas</h2><p>Use the maker’s method for that model; canvas blends and coatings vary. Avoid assuming every canvas shoe is machine washable.</p></article></div><p>Sources: ${escLink('https://www.clarks.com/en-us/editorial/how-to-clean-shoes', 'Clarks material care guide')}, ${escLink('https://www.stuartweitzman.com/content/sw-care-guide', 'Stuart Weitzman care guide')}. Recommendations may differ by finish and construction.</p><h2>Frequently asked</h2><details><summary>Can I put sneakers in a washing machine?</summary><p>Only if the shoe maker says that exact shoe is machine-safe. Leather, suede, glue and foam may be damaged.</p></details><details><summary>Which cleaner fits my shoe?</summary><p>Not known yet unless a manufacturer explicitly identifies a compatible product.</p></details></main>`;++ if (kind === 'kids') return `<main class="sh-exp"><p class="sh-kicker">Baby & kids · fit tools</p><h1>Measure the foot. Age is only a rough label.</h1><p>Baby sizes may use month labels, but manufacturer guidance still recommends measuring. Once children move into numbered sizes, age does not predict shoe size.</p><p>Charts: ${escLink('https://www.striderite.com/pages/sizing-help', 'Stride Rite sizing and foot-length chart')} · ${escLink('https://seekairun.com/', 'See Kai Run brand home')} (use its current sizing chart) · ${escLink('https://www.nike.com/size-fit/kids-footwear', 'Nike Kids footwear chart')}.</p><h2>Stride Rite baby chart examples</h2><p>Brand-specific length guidance (not a universal conversion):</p><ul><li>US size 2 · 3–6 months · foot length 9.9 cm</li><li>US size 3 · 6–9 months · foot length 10.8 cm</li><li>US size 4 · 9–12 months · foot length 11.6 cm</li></ul><p>Read the full current chart for width and later sizes. The maker says age does not reliably map to size after baby shoes.</p><p><a class="sh-btn" href="/kids/measure">Open printable 28 cm ruler & measuring guide</a></p><h2>Ask for a private remeasure reminder</h2>${notice ? `<p class="sh-exp-notice">${esc(notice)}</p>` : ''}${signedIn ? `<form class="sh-exp-form" action="/kids/growth-check" method="post"><label>Nickname (optional) <input name="nickname" maxlength="40" autocomplete="off"></label><label>Current size <input name="size" maxlength="16" required></label><label>Stage <select name="stage"><option value="toddler">Toddler (remeasure in 3 months)</option><option value="child">Child (remeasure in 6 months)</option></select></label><label class="sh-exp-consent"><input type="checkbox" name="consent" value="yes" required> Add a private reminder to my OpenVibe.Today agenda. I understand this stores only the nickname and current shoe size with the reminder; it does not ask for a birthday.</label><button class="sh-btn">Add reminder to Today</button></form>` : '<p><a class="sh-btn" href="/auth/login?next=%2Fkids">Sign in to add a reminder</a></p>'}<h2>Measure again</h2><p>Recheck periodically and whenever shoes feel tight. One manufacturer recommends measuring every two months; the reminder intervals here are the opt-in defaults from this feature brief, not a medical or growth prediction.</p><h2>Frequently asked</h2><details><summary>Can age predict a child’s shoe size?</summary><p>No. Use foot length and that shoe brand’s chart.</p></details><details><summary>What personal details are stored?</summary><p>The Today reminder contains only the optional nickname, current shoe size and reminder date. No birthday is requested.</p></details></main>`;++ if (kind === 'measure') {++ const ticks = Array.from({ length: 29 }, (_, i) => { const x = 20 + i * 40; return `M${x} 10V${i % 5 === 0 ? 55 : 35}`; }).join(' ');++ return `<main class="sh-exp sh-print"><p class="sh-kicker">Print · measure · compare</p><h1>Measure a foot at home</h1><ol><li>Print this page in landscape at 100% / Actual Size with “fit to page” turned off.</li><li>Check that the highlighted calibration segment measures exactly 10 cm. If it does not, do not use the ruler.</li><li>Stand with heel at zero, measure the longest toe, and repeat both feet while wearing usual socks.</li><li>Use the larger measurement with the brand’s current size chart. Foot length is not the same as shoe size.</li></ol><svg class="sh-ruler" viewBox="0 0 1160 80" role="img" aria-label="Printable 28 centimetre ruler with 10 centimetre calibration segment"><path d="${ticks} M20 55H1140 M20 62V68 M420 62V68 M820 62V68 M1140 62V68"/><path class="sh-ruler-calibration" d="M20 60H420"/><text x="20" y="78">0</text><text x="390" y="78">10 cm calibration check</text><text x="790" y="78">20</text><text x="1100" y="78">28 cm</text></svg><p>Ruler length: 28 cm. Calibration segment: 10 cm. Sources: ${escLink('https://www.striderite.com/pages/sizing-help', 'Stride Rite measurement guide')} and ${escLink('https://www.newbalance.co.uk/on/demandware.static/-/Library-Sites-NBUK-NBEU/default/v7f0d890e275d869ed497c2f4ed0e3ec92eb1e357/pdf/Kids_Printable_Size_Chart_2025.pdf', 'New Balance kids printable chart')} (PDF). Manufacturer charts are brand-specific.</p><button class="sh-btn" onclick="window.print()">Print this ruler</button></main>`;++ }++ return '';++}++// ── Search ──────────────────────────────────────────────────function shoesIndex({ models = [], facets = {}, query = {}, page = 1, total = 0, now = Date.now() } = {}) {const perPage = 24;@@@ -335,6 +363,7 @@ ${watchForm({ model, sizes, signedIn, next: `/shoe/${model.slug}` })}<section class="sh-sec" aria-labelledby="sh-colours-h"><h2 id="sh-colours-h">Colourways</h2>${colours.length ? `<ul class="sh-chips">${colours.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '<p class="sh-muted">Not known yet: no offer names a colourway.</p>'}</section>${releases.length ? `<section class="sh-sec" aria-labelledby="sh-releases-h"><h2 id="sh-releases-h">Release dates</h2><p class="sh-fine">From the brand's or the store's own release page, cited. Shoes never enters a raffle.</p><ul class="sh-list">${releases.map((d) => `<li><b>${esc(dayLong(d.release_at))}</b> · ${esc(d.colourway || 'colourway not stated')}${d.price != null ? ` · ${money(d.price, d.currency)}` : ''}${d.sizes.length ? ` · sizes ${d.sizes.slice(0, 12).map(esc).join(', ')}` : ''} · ${sourceLine(d.source_name, d.source_url)}${signedIn ? ` · <a href="/drops#drop-${encodeURIComponent(d.id)}">Remind me</a>` : ''}</li>`).join('')}</ul></section>` : ''}${factsSection(facts)}++<section class="sh-sec" aria-labelledby="sh-care-h"><h2 id="sh-care-h">Keep them clean</h2><p>Start with this shoe’s manufacturer care notes above. If the upper material is not known yet, check the maker’s product page before using water or cleaner.</p><p><a class="sh-btn sh-btn-quiet" href="/care">Open the material care guide</a></p></section><p class="sh-fine">The release facts and care notes above are the source's words, cited with the page and the date Shoes read it. The stores' own pages are the only place to buy; Shoes never takes an order.</p></div>`;return body;}@@@ -438,4 +467,4 @@ function notFound() {return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;}--module.exports = { home, shoesIndex, shoe, drops, watches, watchPage, watchGate, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };++module.exports = { home, shoesIndex, shoe, drops, watches, watchPage, watchGate, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable, expansionPage };@@@ -41,6 +41,24 @@ const DEALS = [store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers' },latest_observation: { price: '40.00', list_price: null, shipping: null, condition: null, currency: 'USD', observed_at: DAY },},++ {++ id: 'dof_test_socks', slug: 'hiker-micro-crew-socks',++ title: "Darn Tough Hiker Micro Crew Socks Men's", link: 'https://darntough.com/products/hiker-micro-crew',++ store: { domain: 'darntough.com', name: 'Darn Tough' }, product: { slug: 'hiker-micro-crew-socks', name: 'Hiker Micro Crew Socks' },++ latest_observation: { price: '25.00', shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_laces', slug: 'flat-cotton-laces',++ title: 'Flat Cotton Shoelaces 120 cm', link: 'https://www.lacelab.com/products/flat-cotton-laces',++ store: { domain: 'lacelab.com', name: 'Lace Lab' }, product: { slug: 'flat-cotton-laces', name: 'Flat Cotton Shoelaces' },++ latest_observation: { price: '8.00', shipping: '2.00', condition: 'new', currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_cleaner', slug: 'sneaker-cleaning-kit',++ title: 'Sneaker Cleaning Kit', link: 'https://jasonmarkk.com/products/essential-kit',++ store: { domain: 'jasonmarkk.com', name: 'Jason Markk' }, product: { slug: 'sneaker-cleaning-kit', name: 'Sneaker Cleaning Kit' },++ latest_observation: { price: '18.00', shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },++ },{id: 'dof_test_990', slug: 'new-balance-990v6',title: 'New Balance 990v6 Running Shoe', link: 'https://www.newbalance.com/pd/990v6',@@@ -54,6 +72,9 @@ const PRODUCTS = {'air-jordan-1-mid': { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid', brand: 'Jordan' },'samba-og': { slug: 'samba-og', name: 'Samba OG', brand: 'Adidas' },'rival-fleece-joggers': { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers', brand: 'Under Armour' },++ 'hiker-micro-crew-socks': { slug: 'hiker-micro-crew-socks', name: 'Hiker Micro Crew Socks', brand: 'Darn Tough' },++ 'flat-cotton-laces': { slug: 'flat-cotton-laces', name: 'Flat Cotton Shoelaces', brand: 'Lace Lab' },++ 'sneaker-cleaning-kit': { slug: 'sneaker-cleaning-kit', name: 'Sneaker Cleaning Kit', brand: 'Jason Markk' },'990v6': { slug: '990v6', name: '990v6', brand: 'New Balance' },};@@@ -10,6 +10,7 @@ const configLib = require('../server/config');const { testDb } = require('./helpers/db');const { createStore } = require('../server/db');const { createCatalogue } = require('../server/catalogue');++const { createProductStore } = require('../server/product-store');const { createUpstreams } = require('../server/upstreams');const { createOffersJob } = require('../server/jobs/offers');const { createDropsJob } = require('../server/jobs/drops');@@@ -29,17 +30,20 @@ const { startUpstreams } = require('./helpers/upstreams');});const s = createStore(test.db, { now: () => Date.parse('2026-10-31T22:00:00Z') });const catalogue = createCatalogue(s, { staleHours: 48 });++ const productStore = createProductStore(s);const upstreams = createUpstreams({ config, log: { warn() {}, log() {} } });-- const offersJob = createOffersJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });++ const offersJob = createOffersJob({ config, catalogue, products: productStore, upstreams, log: { log() {}, warn() {} } });const dropsJob = createDropsJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });const ebayJob = createEbayJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });try {-- await check('offers: only footwear, brand from the product, one model each', async () => {++ await check('offers: shoes and categorized accessories, each with its public product source', async () => {const out = await offersJob.runOnce({ pages: 2 });-- equal(out.seen, 5);-- equal(out.candidates, 4, 'the joggers are not footwear');++ equal(out.seen, 8);++ equal(out.candidates, 7, 'the joggers are not a shoe or shoe accessory');equal(out.offers, 4);++ equal(out.products, 3);++ equal(out.accessory_offers, 3);const counts = await catalogue.counts();equal(counts.models, 4);equal(counts.offers, 4);@@@ -56,11 +60,17 @@ const { startUpstreams } = require('./helpers/upstreams');equal(offers.length, 1);equal(offers[0].all_in, 89.95);equal(offers[0].drop_from_list, 40.05);++ const socks = await productStore.list({ kind: 'socks' });++ equal(socks.length, 1);++ equal(socks[0].source.name, 'OpenVibe.Deals');++ equal((await productStore.offers(socks[0].id))[0].all_in, 25);});await check('offers: a second run does not duplicate an offer or snapshots', async () => {const out = await offersJob.runOnce({ pages: 2 });equal(out.offers, 4);++ equal(out.accessory_offers, 3);++ equal((await productStore.list({ kind: 'laces' })).length, 1);const counts = await catalogue.counts();equal(counts.offers, 4);equal(counts.snapshots, 4);@@@ -98,6 +98,28 @@ const { assert, equal } = require('./helpers/check');assert(sent.headers.authorization.startsWith('Bearer '), 'the person token');});++ await check('POST /kids/growth-check: opt-in creates a private reminder without a birthday', async () => {++ const before = t.upstreams.state.todayRequests.length;++ const noConsent = await t.get('/kids/growth-check', { as: user, method: 'POST', form: { nickname: 'M', size: '7', stage: 'toddler' } });++ equal(noConsent.status, 302);++ equal(t.upstreams.state.todayRequests.length, before);++++ const res = await t.get('/kids/growth-check', { as: user, method: 'POST', form: { consent: 'yes', nickname: 'M', size: '7', stage: 'toddler' } });++ equal(res.status, 302);++ equal(t.upstreams.state.todayRequests.length, before + 1);++ const sent = t.upstreams.state.todayRequests[before].body;++ equal(sent.kind, 'event');++ equal(sent.visibility, 'private');++ assert(sent.title.includes('M'), 'nickname can label the reminder');++ assert(sent.description.includes('Current shoe size: 7'), 'only current size is included');++ assert(!/birthday|birthdate|date of birth/i.test(sent.description.replace('This reminder contains no birthday.', '')), 'no birthday value is sent');++ const due = new Date(sent.starts_at);++ const now = new Date();++ assert(due > now, 'the reminder is in the future');++ const monthDistance = (due.getUTCFullYear() - now.getUTCFullYear()) * 12 + due.getUTCMonth() - now.getUTCMonth();++ equal(monthDistance, 3);++ });++await check('GET /track and POST /track: a pasted link becomes a watch', async () => {const page = await t.get('/track', { as: user });equal(page.status, 200);@@@ -130,6 +152,29 @@ const { assert, equal } = require('./helpers/check');assert(h.includes('OpenVibe.Watch'), 'the watch service');});++ await check('new product kinds, cited size tools and expansion guides are server rendered', async () => {++ const products = await t.get('/api/v1/products?kind=shoe');++ equal(products.status, 200);++ assert(Array.isArray(products.json().products), 'products envelope');++ const care = await t.get('/api/v1/products?kind=care');++ equal(care.status, 200, care.text);++ assert(care.json().products.some((p) => p.kind === 'care'), 'Deals care product is cataloged with provenance');++ equal(care.json().source_note, null);++ const lace = await t.get('/api/v1/tools/lace-length?eyelet_pairs=6');++ equal(lace.status, 200);++ equal(lace.json().cm, 105);++ assert(lace.json().source.url.includes('fieggen.com'), 'formula is cited');++ const sock = await t.get('/api/v1/sizes/convert?brand=darn-tough&system=women&size=8');++ equal(sock.json().conversions[0].size, 'M');++ const laces = await t.get('/laces');++ assert(laces.text.includes('Bow-tie / Army') && laces.text.includes('Ian's Shoelace Site'), 'lacing guide and citation');++ const ruler = await t.get('/kids/measure');++ assert(ruler.text.includes('10 cm') && ruler.text.includes('Actual Size'), 'print ruler and calibration directions');++ assert(ruler.text.includes('HowTo'), 'structured how-to data');++ const kids = await t.get('/kids', { as: user });++ assert(kids.text.includes('does not ask for a birthday'), 'no birth date collected');++ });++await check('discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt', async () => {const robots = await t.get('/robots.txt');equal(robots.status, 200);nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++++const { check, done, equal, assert } = require('./helpers/check');++const { laceLength, sizeConvert, asProduct, KINDS } = require('../server/product-catalog');++++(async () => {++ await check('lacing estimates use the cited formula and disclose assumptions', () => {++ const cross = laceLength({ eyeletPairs: 6 });++ equal(cross.cm, 105);++ equal(cross.inches, 40);++ equal(cross.source.name, "Ian's Shoelace Site");++ assert(cross.assumptions.horizontal_cm > 0);++ equal(laceLength({ eyeletPairs: 6, method: 'bow-tie' }).cm, 90);++ equal(laceLength({ eyeletPairs: 99 }), null);++ });++ await check('brand size conversion is sourced and rejects unknown charts', () => {++ equal(sizeConvert({ system: 'women', size: '8' }).conversions[0].size, 'M');++ equal(sizeConvert({ system: 'kids', size: '1' }).conversions[0].size, 'M');++ equal(sizeConvert({ system: 'men', size: 15 }).conversions.length, 0);++ equal(sizeConvert({ system: 'men', size: 10, brand: 'other' }), null);++ });++ await check('product kinds and shoe wire shape match the shared contract', () => {++ equal(KINDS, ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids']);++ const p = asProduct({ slug: 'test-shoe', brand: 'Test', model: 'Runner', first_seen: '2026-11-01T00:00:00Z' });++ equal(p.id, 'test-shoe');++ equal(p.kind, 'shoe');++ equal(p.facts, []);++ });++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -40,6 +40,10 @@ pasted Marketplace link is handed to Watch, which applies its own fetch rules.
@@ -40,6 +40,10 @@ pasted Marketplace link is handed to Watch, which applies its own fetch rules.
| `/track` | paste a listing link; it becomes a Watch |
| `/track` | paste a listing link; it becomes a Watch |
| `/watches` | a signed-in person's watches (private, no-store) |
| `/watches` | a signed-in person's watches (private, no-store) |
| `/developers` | the API, sources and examples |
| `/developers` | the API, sources and examples |
+| `/laces` · `/lace-length` | lacing diagrams and a source-cited length estimate from measured eyelet spacing |
+| `/socks` | brand-specific size conversion and use/material guidance |
+| `/care` | maker-cited care notes by material, with unknown compatibility stated clearly |
+| `/kids` · `/kids/measure` | manufacturer charts, a calibrated printable ruler, and an opt-in private Today remeasure reminder |
| `/api/openapi.json` | the OpenAPI 3.1 document |
| `/api/openapi.json` | the OpenAPI 3.1 document |
All pages are server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js`, `kit.card`,
All pages are server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js`, `kit.card`,
@@ -55,6 +59,10 @@ Everything under `/api/v1`; errors are `application/problem+json` with a `code`.
@@ -55,6 +59,10 @@ Everything under `/api/v1`; errors are `application/problem+json` with a `code`.
| `GET /api/v1/shoes?q&brand&size&condition&store&sort&page&limit` | `{ shoes, facets, page, per_page, total, sort, filters }`; sorts `recent`, `drop`, `price`, `price_desc`, `popular`, `name` |
| `GET /api/v1/shoes?q&brand&size&condition&store&sort&page&limit` | `{ shoes, facets, page, per_page, total, sort, filters }`; sorts `recent`, `drop`, `price`, `price_desc`, `popular`, `name` |
| `GET /api/v1/shoes/:slug` | `{ shoe, offers, history, curve, facts, drops }` |
| `GET /api/v1/shoes/:slug` | `{ shoe, offers, history, curve, facts, drops }` |
| `GET /api/v1/shoes/:slug/price?size=&condition=` | `{ slug, size, condition, price, currency, checked_at, offers }` — the URL a Watch polls |
| `GET /api/v1/shoes/:slug/price?size=&condition=` | `{ slug, size, condition, price, currency, checked_at, offers }` — the URL a Watch polls |
+| `GET /api/v1/products?kind=&q=&size=` | shared catalog envelope with kinds `shoe`, `laces`, `socks`, `care`, `insoles`, `kids` (`shoes.product@1`) |
+| `GET /api/v1/products/:id` · `/offers` | one cited product or its offer list |
+| `GET /api/v1/tools/lace-length?eyelet_pairs=&method=&horizontal_cm=&vertical_cm=&ends_cm=` | measured lace estimate in cm/inches using a cited formula |
+| `GET /api/v1/sizes/convert?brand=darn-tough&system=men\|women\|kids&size=` | brand-chart conversion to sock size |
| `GET /api/v1/drops?from&to` | `{ from, to, drops }`, soonest first |
| `GET /api/v1/drops?from&to` | `{ from, to, drops }`, soonest first |
| `GET /api/v1/drops/:id` | one drop |
| `GET /api/v1/drops/:id` | one drop |
| `GET /api/v1/me/watches` | session only: the person's watches |
| `GET /api/v1/me/watches` | session only: the person's watches |
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+-- phase: expand
+-- The shared product catalog can grow beyond shoe_models without changing the established shoe/offer tables.
+-- Accessory rows are published only after a cited, permitted source is configured.
+CREATE TABLE shoe_products (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL,
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ title text COLLATE "C" NOT NULL,
+ brand text COLLATE "C",
+ model text COLLATE "C",
+ url text,
+ image text,
+ sizes jsonb NOT NULL DEFAULT '[]'::jsonb,
+ materials jsonb NOT NULL DEFAULT '[]'::jsonb,
+ uses jsonb NOT NULL DEFAULT '[]'::jsonb,
+ source_name text COLLATE "C" NOT NULL,
+ source_url text NOT NULL,
+ checked_at timestamptz NOT NULL,
+ facts jsonb NOT NULL DEFAULT '[]'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_products_kind CHECK (kind IN ('shoe', 'laces', 'socks', 'care', 'insoles', 'kids')),
+ CONSTRAINT shoe_products_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 160),
+ CONSTRAINT shoe_products_source_url CHECK (source_url ~ '^https?://'),
+ CONSTRAINT shoe_products_url CHECK (url IS NULL OR url ~ '^https?://'),
+ CONSTRAINT shoe_products_image CHECK (image IS NULL OR image ~ '^https?://')
+);
+CREATE INDEX shoe_products_kind_title ON shoe_products (kind, lower(title));
+CREATE INDEX shoe_products_checked ON shoe_products (checked_at DESC);
+
+CREATE TABLE shoe_product_offers (
+ id text COLLATE "C" PRIMARY KEY,
+ product_id text COLLATE "C" NOT NULL REFERENCES shoe_products (id) ON DELETE CASCADE,
+ source text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ store_domain text COLLATE "C" NOT NULL,
+ store_name text,
+ title text COLLATE "C" NOT NULL,
+ url text NOT NULL,
+ price_amount numeric(10,2),
+ shipping_amount numeric(10,2),
+ currency text COLLATE "C" NOT NULL DEFAULT 'USD',
+ size text COLLATE "C",
+ condition text COLLATE "C",
+ checked_at timestamptz NOT NULL,
+ stale boolean NOT NULL DEFAULT false,
+ raw jsonb NOT NULL DEFAULT '{}'::jsonb,
+ CONSTRAINT shoe_product_offers_unique UNIQUE (source, source_id),
+ CONSTRAINT shoe_product_offers_url CHECK (url ~ '^https?://'),
+ CONSTRAINT shoe_product_offers_price CHECK (price_amount IS NULL OR price_amount >= 0),
+ CONSTRAINT shoe_product_offers_shipping CHECK (shipping_amount IS NULL OR shipping_amount >= 0)
+);
+CREATE INDEX shoe_product_offers_product ON shoe_product_offers (product_id, checked_at DESC);
@@ -135,3 +135,5 @@
@@ -135,3 +135,5 @@
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
}
+.sh-exp{max-width:900px;margin:0 auto;padding:clamp(18px,4vw,48px);color:var(--text-primary,#17191f)}.sh-exp h1{max-width:18ch;font-size:clamp(2rem,5vw,3.4rem);line-height:1.04;letter-spacing:-.04em}.sh-exp h2{margin-top:2.4rem}.sh-exp p,.sh-exp li{max-width:72ch;line-height:1.65}.sh-exp a{color:var(--accent-strong,#b02a2d)}.sh-exp-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:14px;margin:24px 0;padding:20px;border:1px solid var(--border,rgba(0,0,0,.16));border-radius:18px;background:var(--bg-secondary,#f7f5f2)}.sh-exp-form label{display:grid;gap:6px;font-weight:650}.sh-exp-form input,.sh-exp-form select{min-height:46px;max-width:100%;padding:8px 12px;border:1px solid var(--border,#aaa);border-radius:10px;background:var(--bg-primary,#fff);color:inherit;font:inherit}.sh-exp-form button,.sh-exp .sh-btn{min-height:46px;align-self:end}.sh-exp-consent{grid-column:1/-1;display:flex!important;align-items:flex-start}.sh-exp-consent input{flex:none;min-width:22px;min-height:22px}.sh-exp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px}.sh-exp-grid article,.sh-exp-result{padding:18px;border:1px solid var(--border,rgba(0,0,0,.16));border-radius:16px;background:var(--bg-secondary,#f7f5f2)}.sh-lace-diagram{width:120px;height:90px;float:right}.sh-lace-diagram path{fill:none;stroke:var(--accent-strong,#b02a2d);stroke-width:3}.sh-lace-diagram circle{fill:var(--text-primary,#17191f)}.sh-exp details{padding:12px 0;border-bottom:1px solid var(--border,rgba(0,0,0,.16))}.sh-exp summary{min-height:44px;display:flex;align-items:center;font-weight:700;cursor:pointer}.sh-ruler{display:block;width:min(100%,290mm);height:20mm;margin:24px auto;overflow:visible}.sh-ruler path{fill:none;stroke:currentColor;stroke-width:1}.sh-ruler text{font-size:12px;fill:currentColor}.sh-exp-notice{padding:12px;background:var(--bg-secondary,#f7f5f2);border-radius:10px}@media(prefers-color-scheme:dark){.sh-exp{color:var(--text-primary,#f5f4f1)}}@page{size:A4 landscape;margin:5mm}@media print{body *{visibility:hidden!important}.sh-print,.sh-print *{visibility:visible!important}.sh-print{position:absolute;inset:0;width:100%;max-width:none;margin:0;padding:0;color:#000!important}.sh-print .sh-btn{display:none!important}.sh-ruler{width:290mm;height:20mm}}
+.sh-home-categories{max-width:1180px;margin:12px auto 28px;padding:0 16px}.sh-home-categories h2{margin:8px 0 12px;font-size:clamp(20px,3vw,28px)}.sh-home-categories>div{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.sh-home-categories>div>a{position:relative;display:grid;align-content:center;gap:5px;min-height:78px;padding:12px 34px 12px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:15px;background:var(--bg-secondary,#111827);color:var(--text-primary,#eef2fb);text-decoration:none}.sh-home-categories>div>a:hover{border-color:var(--room-accent,#ff7e76);transform:translateY(-1px)}.sh-home-categories b{font-size:15px}.sh-home-categories span{font-size:12px;line-height:1.35;color:var(--text-secondary,#9aa8bd)}.sh-home-categories i{position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:20px;font-style:normal;color:var(--room-accent,#ff7e76)}.sh-home-categories>p{margin:12px 0}.sh-home-categories>p>a{display:inline-flex;align-items:center;min-height:44px;padding:0 10px;color:var(--room-accent,#ff7e76)}.sh-ruler-calibration{stroke:var(--room-accent,#ff7e76)!important;stroke-width:4!important}@media(max-width:760px){.sh-home-categories>div{grid-template-columns:repeat(2,minmax(0,1fr))}.sh-home-categories>div>a{min-height:74px}.sh-home-categories>div>a span{font-size:12px}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+# Open.Shoes expansion research
+
+Research date: 2026-11-09. Public pages were reviewed without accounts. Competitor features below describe the product’s public positioning, not a full usability audit.
+
+## Market and competitor map
+
+| Segment | Examples | What they are strong at | Product opportunity for Open.Shoes |
+|---|---|---|---|
+| General shoe retail | [Zappos](https://www.zappos.com/), [Foot Locker](https://www.footlocker.com/) | Broad retail inventory, brand and size navigation, straightforward purchase availability | Aggregate all-in prices and size stock across sources, with a model history and source timestamps |
+| Sneaker resale | [StockX](https://stockx.com/), [GOAT](https://www.goat.com/) | Authentication/resale context and model/colourway discovery | Show new retail alongside resale only when source and condition are explicit; avoid implying authentication |
+| Deal discovery | [Sole Retriever](https://www.soleretriever.com/), [Kicks Deals](https://www.kicksdeals.com/) | Release/deal discovery and sneaker-focused editorial | Combine official release links with price alerts and unambiguous all-in offer rows |
+| Running research and specialty retail | [RunRepeat](https://runrepeat.com/), [Running Warehouse](https://www.runningwarehouse.com/), [Running Shoe Deals methodology](https://bestrunningshoedeals.com/methodology) | RunRepeat emphasizes testing/reviews; Running Warehouse has specialist product information; comparison sites show the value of exact model matching and size-aware price comparison | Add cited fit/spec facts and model-level size-price history; do not claim fit advice without a cited source |
+| Kids | [Stride Rite sizing help](https://www.striderite.com/pages/sizing-help), [See Kai Run](https://seekairun.com/), [Pediped](https://pediped.com/), [Nike Kids](https://www.nike.com/w/kids-shoes-v4dhzy7ok), [Adidas Kids](https://www.adidas.com/us/kids-shoes) | Brand charts, printable measuring resources, stage/age-oriented navigation; Stride Rite explicitly says age is not a reliable shoe-size proxy beyond babies | Keep manufacturer-specific foot-length charts, label age as broad guidance, provide a print-and-measure page and opt-in check-in reminders |
+| Socks | [Bombas](https://bombas.com/), [Darn Tough](https://darntough.com/), [Stance](https://www.stance.com/), [Balega](https://balega.com/) | Brand-specific sizing and clear use/material positioning (running, hiking, everyday, compression) | Cross-brand size lookup from shoe size with each brand chart cited; filter by use and material |
+| Laces | [Lace Lab](https://www.lacelab.com/), [Kiwi](https://www.kiwishoecare.com/), [Ian’s Shoelace Site](https://www.fieggen.com/shoelace/) | Lace styles/lengths and practical lacing instructions | Eyelet-pair + method calculator; link the right length/type to each shoe only when known |
+| Shoe care | [Jason Markk](https://jasonmarkk.com/), [Crep Protect](https://crepprotect.com/), [Reshoevn8r](https://reshoevn8r.com/) | Material-oriented cleaning kits and product education | Material guide (leather, suede, knit, canvas, mesh, nubuck) with product compatibility and citations |
+
+The category expansion is commercially coherent: shoes bring intent, while socks, laces, care and insoles are adjacent replenishment or maintenance needs. Kids adds a distinct fit/measurement use case and recurring size checks. Treat revenue/market size as unquantified here; this pass is a competitor and source map, not a financial forecast.
+
+## Source and access assessment
+
+| Source | Access | Suitable use | Gate / caveat |
+|---|---|---|---|
+| OpenVibe.Deals | Existing public network feed; no key | Initial offers across supported product kinds | Confirm Deals’ current footwear/accessory classification and terms before expanding ingestion |
+| eBay Browse API | Official API, existing Shoes client | Search/offer summaries for items | Owner must provision developer key pair; existing code already gates this source |
+| Brand/store public Shopify storefront catalog (`/products.json`) | Sometimes public without auth | Product metadata and variants for participating DTC brands | Per-store permission/terms and request limits need review; public reachability is not blanket reuse permission; no price ingestion until terms are checked |
+| Manufacturer size/care charts | Public pages/PDFs | Cited shoe/sock size conversion, kids foot-length tables, care compatibility | Preserve chart owner, URL, access date, units, region and model applicability; never infer one brand’s sizing from another |
+| Open product-data datasets | No broadly established apparel equivalent confirmed in this search | Potential identifiers/material metadata | Candidate source only; licensing and coverage require separate review |
+| Marketplace feeds and retailer affiliate APIs | Account/key-dependent | Wider retail coverage | Owner gate: no account creation or spend; do not enable until owner supplies credentials and approves terms |
+
+Do not scrape Facebook Marketplace, automate raffles/draws, add affiliate links, or treat public HTML/catalog endpoints as permission by themselves. Missing credentials should continue to disable only that source with a plain status note.
+
+## Modular product/API proposal
+
+Use one typed product record for `shoe|laces|socks|care|insoles|kids`, with stable id, kind, brand, line/model, title, URL, image, variant/size fields, material/use tags, cited facts, source identity and check time. Offers should retain the network’s shared offer shape and source provenance. A product without a verified attribute should return null/unknown and render “not known yet.”
+
+Proposed public endpoints from the brief: `GET /api/v1/products?kind=&q=&size=`, `GET /api/v1/products/:id/offers`, `GET /api/v1/tools/lace-length`, and `GET /api/v1/sizes/convert`. Keep the existing `/shoes` routes stable while this schema is introduced. Put `shoes.product@1` in Contracts and `ov.shoes.*` in SDK as separate library PRs; add the reusable `offerCard()` to Shared only if no compatible shared card exists. Deals can then consume Shoes’ public product search without sharing Shoes-specific persistence.
+
+The kid-size reminder should store only a nickname and current size, require explicit opt-in, and schedule 3-month toddler / 6-month child check-ins through Today. Do not collect date of birth. Brand growth charts are guidance, not a guarantee that age predicts size.
+
+## Evidence checked
+
+- Stride Rite sizing help, accessed 2026-11-09: https://www.striderite.com/pages/sizing-help — states age is not a reliable size proxy except for babies and links fit guidance.
+- Stride Rite FAQ, accessed 2026-11-09: https://www.striderite.com/pages/stride-rite-faq — describes home measurement and printable chart options.
+- Running Shoe Deals methodology, accessed 2026-11-09: https://bestrunningshoedeals.com/methodology — describes matching models across 44 stores and size-specific price tracking; useful competitive benchmark.
+- Ian’s Shoelace Site, accessed 2026-11-09: https://www.fieggen.com/shoelace/ — primary practical reference for lacing patterns and lace length guidance; cite the exact method/table used in the eventual calculator.
+- eBay Browse API overview, accessed 2026-11-09: https://developer.ebay.com/api-docs/buy/browse/overview.html — official API documentation; credentials remain an owner gate.
+- Shopify storefront docs, accessed 2026-11-09: https://shopify.dev/docs/api/storefront — official documentation; use a supported Storefront API with owner-provided access rather than assuming an exposed catalog endpoint is licensed.
+
+## Limitations
+
+This quick web pass confirms named competitors and primary sizing/API references, but does not establish market size, retailer catalog reuse rights, or a durable apparel-wide open dataset. Those need source-by-source legal/coverage checks before automated ingestion. Requested 390 px and 1280 px UI screenshots belong with the implemented pages and should be generated from the running local service after the UI changes.
+
+## Deeper source check and what the build adds
+
+Additional primary sources checked 2026-11-09:
+
+- [Ian Fieggen’s shoelace formulas](https://www.fieggen.com/shoelace/lengthformulas.htm) expose measured eyelet count, across-shoe spacing, row spacing and tie-end length; the estimator uses the Criss Cross and Bow Tie/Army formulas and returns the assumptions.
+- [Darn Tough’s current sock size chart](https://darntough.com/pages/size-chart) maps distinct US men’s, women’s and kids’ shoe-size ranges to its own sock letters and advises sizing up between categories. The API conversion is limited to this brand chart; it does not claim a universal sock size.
+- [Stride Rite’s kids sizing guide](https://www.striderite.com/pages/sizing-help) gives foot lengths and some baby month labels, while saying age is not a dependable proxy after baby sizes. The page shows three explicit chart examples and links the full current table.
+- [Clarks’ shoe care guide](https://www.clarks.com/en-us/editorial/how-to-clean-shoes) distinguishes canvas/fabric from leather and suede. [Stuart Weitzman’s guide](https://www.stuartweitzman.com/content/sw-care-guide) cautions against some suede brushes and recommends professional care for some leather cleaning. This disagreement is why Shoes links to the maker and avoids one-method-fits-all advice.
+- [Open Products Facts’ dataset entry](https://www.data.gouv.fr/datasets/open-products-facts) identifies the ODbL. It is a plausible later identifier/material source, but share-alike obligations and apparel coverage need evaluation before importing it. It was not needed to ship the no-cost public Deals feed integration.
+- Public user discussions are anecdotal, not care authorities: [SneakerRestoration cleaning discussion](https://www.reddit.com/r/SneakerRestoration/comments/1q790so/how_to_clean_them/) and [running shoe cleaning discussion](https://www.reddit.com/r/AskRunningShoeGeeks/comments/1ruwpup/how_do_you_clean_your_running_shoes/) show conflicting preferences about hand-cleaning, machine washing and whether to clean at all. The product therefore uses manufacturer instructions for advice.
+
+### Three useful additions beyond the brief
+
+1. **A calibration step for the printable ruler.** The page includes a 10 cm check on a 28 cm ruler and says to discard the measurement if printing scaled it. It also asks the parent to measure both feet and use the larger length. This prevents a mis-scaled printout or the smaller foot from silently producing a false size. Built at `/kids/measure`.
+2. **A material-first risk check before recommending cleaners.** Model pages lead with that model’s cited care notes; the guide says to follow the most delicate material and not assume machine washing is safe. It says product compatibility is unknown until a maker confirms it. Built at `/care` and linked from every shoe model page.
+3. **Show the chart and source boundary on every conversion.** Sock conversion reports its chart URL and only answers for Darn Tough’s brand-specific ranges. Between-brand size translation stays unknown. Built in `GET /api/v1/sizes/convert`.
+
+## UI evidence
+
+Screenshots are committed in this PR under `research-2026-11-09/shoes/screenshots/`: home and printable measuring page, each at 390 px and 1280 px in dark and light browser color schemes. The local service was loaded from the development PGlite store; no production data was written.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-dark.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-light.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-dark.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-light.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/home-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-dark.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-light.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-dark.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-light.png differ
Binary files /dev/null and b/research-2026-11-09/shoes/screenshots/kids-measure-390-light.png differ
@@ -34,6 +34,7 @@ const { openapi } = require('./http/openapi');
@@ -34,6 +34,7 @@ const { openapi } = require('./http/openapi');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { assetVersion, setRelease } = require('./render/layout');
const { assetVersion, setRelease } = require('./render/layout');
const { createCatalogue } = require('./catalogue');
const { createCatalogue } = require('./catalogue');
+const { createProductStore } = require('./product-store');
const { createUpstreams } = require('./upstreams');
const { createUpstreams } = require('./upstreams');
const { createJobs } = require('./jobs');
const { createJobs } = require('./jobs');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
@@ -62,8 +63,9 @@ async function createApp(opts = {}) {
@@ -62,8 +63,9 @@ async function createApp(opts = {}) {
// The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.
// The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours, sharing });
const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours, sharing });
+ const products = createProductStore(s);
const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
- const jobs = createJobs({ config, catalogue, upstreams, log });
+ const jobs = createJobs({ config, catalogue, products, upstreams, log });
// The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).
// The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).
const searchIndex = createSearchIndex({ config, s, log });
const searchIndex = createSearchIndex({ config, s, log });
@@ -74,7 +76,7 @@ async function createApp(opts = {}) {
@@ -74,7 +76,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, sharing, accountData, accountSend, log };
+ const ctx = { config, s, keys, sso, principal, catalogue, products, upstreams, jobs, searchIndex, sharing, accountData, accountSend, log };
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
@@ -22,13 +22,14 @@ const express = require('express');
@@ -22,13 +22,14 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { openapi } = require('./openapi');
const { openapi } = require('./openapi');
+const productCatalog = require('../product-catalog');
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);
const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);
const CONDITIONS = new Set(['new', 'used', 'deadstock']);
const CONDITIONS = new Set(['new', 'used', 'deadstock']);
function createApi(ctx) {
function createApi(ctx) {
- const { config, catalogue, principal, upstreams } = ctx;
+ const { config, catalogue, products: productStore, principal, upstreams } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -95,6 +96,57 @@ function createApi(ctx) {
@@ -95,6 +96,57 @@ function createApi(ctx) {
return res.json({ slug, size, condition, price: out.price, currency: 'USD', checked_at: out.checked_at, offers: out.offers });
return res.json({ slug, size, condition, price: out.price, currency: 'USD', checked_at: out.checked_at, offers: out.offers });
});
});
+ // One catalog envelope for every product kind. Until a cited and permitted feed is configured, accessories
+ // correctly return an empty result with a plain source note instead of invented products or prices.
+ r.get('/products', async (req, res) => {
+ const kind = str(req.query.kind, 20) || null;
+ if (kind && !productCatalog.KINDS.includes(kind)) return fail(res, req, 400, 'product.kind_invalid', 'kind must be shoe, laces, socks, care, insoles or kids.');
+ const q = str(req.query.q, 80).toLowerCase();
+ const size = str(req.query.size, 20);
+ const models = kind && kind !== 'shoe' ? [] : await catalogue.listShoes({ q: q || null, size: size || null, limit: 100, offset: 0 });
+ const shoeProducts = models.filter((m) => !q || `${m.brand} ${m.model}`.toLowerCase().includes(q)).map((m) => productCatalog.asProduct(m));
+ const otherProducts = kind === 'shoe' ? [] : await productStore.list({ kind, q: q || null, size: size || null, limit: 100, offset: 0 });
+ const resultProducts = [...shoeProducts, ...otherProducts].slice(0, 100);
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ products: resultProducts, total: resultProducts.length, kind, source_note: kind && resultProducts.length === 0 ? 'No cited, permitted product catalog is available for this kind yet; no product or offer data is being inferred.' : null });
+ });
+
+ r.get('/products/:id', async (req, res) => {
+ const slug = str(req.params.id, 160);
+ const stored = await productStore.bySlug(slug);
+ if (stored) return res.json({ product: stored });
+ const model = await catalogue.modelBySlug(slug);
+ if (!model) return fail(res, req, 404, 'product.not_found', 'No product with that id.');
+ return res.json({ product: productCatalog.asProduct(model, await catalogue.factsOf(model.id)) });
+ });
+
+ r.get('/products/:id/offers', async (req, res) => {
+ const id = str(req.params.id, 160);
+ const stored = await productStore.bySlug(id);
+ if (stored) return res.json({ product_id: stored.id, offers: await productStore.offers(id) });
+ const model = await catalogue.modelBySlug(id);
+ if (!model) return fail(res, req, 404, 'product.not_found', 'No product with that id.');
+ const offers = await catalogue.offersOf(model.id);
+ return res.json({ product_id: model.slug, offers });
+ });
+
+ r.get('/tools/lace-length', (req, res) => {
+ const result = productCatalog.laceLength({
+ eyeletPairs: Number(req.query.eyelet_pairs), method: str(req.query.method, 30) || 'criss-cross',
+ horizontalCm: req.query.horizontal_cm === undefined ? 3.5 : Number(req.query.horizontal_cm),
+ verticalCm: req.query.vertical_cm === undefined ? 2.5 : Number(req.query.vertical_cm),
+ endsCm: req.query.ends_cm === undefined ? 25 : Number(req.query.ends_cm),
+ });
+ if (!result) return fail(res, req, 400, 'lace.input_invalid', 'Use 2–16 eyelet pairs, criss-cross or bow-tie, and valid spacing/end measurements in cm.');
+ return res.json(result);
+ });
+
+ r.get('/sizes/convert', (req, res) => {
+ const result = productCatalog.sizeConvert({ system: str(req.query.system, 20), size: str(req.query.size, 20), brand: str(req.query.brand, 40) || 'darn-tough' });
+ if (!result) return fail(res, req, 400, 'size.input_invalid', 'Provide a Darn Tough shoe-size system (men, women or kids) and a numeric shoe size.');
+ return res.json(result);
+ });
+
r.get('/drops', async (req, res) => {
r.get('/drops', async (req, res) => {
const from = req.query.from ? new Date(String(req.query.from)) : new Date(Date.now() - 3600_000);
const from = req.query.from ? new Date(String(req.query.from)) : new Date(Date.now() - 3600_000);
const to = req.query.to ? new Date(String(req.query.to)) : null;
const to = req.query.to ? new Date(String(req.query.to)) : null;
@@ -57,6 +57,12 @@ function publicPages(models = []) {
@@ -57,6 +57,12 @@ function publicPages(models = []) {
{ path: '/', changefreq: 'hourly', priority: 1.0, title: 'Open.Shoes home', text: 'Every shoe, every market, the right price: all-in prices, the price history and the size-price curve.' },
{ path: '/', changefreq: 'hourly', priority: 1.0, title: 'Open.Shoes home', text: 'Every shoe, every market, the right price: all-in prices, the price history and the size-price curve.' },
{ path: '/shoes', changefreq: 'hourly', priority: 0.9, title: 'Every shoe', text: 'Search every model by brand, size and condition; every offer is all-in with its store and the time it was checked.' },
{ path: '/shoes', changefreq: 'hourly', priority: 0.9, title: 'Every shoe', text: 'Search every model by brand, size and condition; every offer is all-in with its store and the time it was checked.' },
{ path: '/drops', changefreq: 'daily', priority: 0.8, title: 'The drops calendar', text: 'Official shoe release dates, each cited to the brand\'s or store\'s own release page, with a reminder on OpenVibe.Today.' },
{ path: '/drops', changefreq: 'daily', priority: 0.8, title: 'The drops calendar', text: 'Official shoe release dates, each cited to the brand\'s or store\'s own release page, with a reminder on OpenVibe.Today.' },
+ { path: '/laces', changefreq: 'monthly', priority: 0.7, title: 'Laces and lacing guides', text: 'Measured shoelace length estimates, sourced formulas and illustrated lacing guides.' },
+ { path: '/lace-length', changefreq: 'monthly', priority: 0.7, title: 'Lace length finder', text: 'Estimate length from eyelet count, measured spacing and lacing method.' },
+ { path: '/socks', changefreq: 'monthly', priority: 0.7, title: 'Sock sizing by shoe size', text: 'Brand-specific sock size conversion and use/material guidance.' },
+ { path: '/care', changefreq: 'monthly', priority: 0.7, title: 'Shoe care by material', text: 'Manufacturer-cited care guidance for shoe upper materials.' },
+ { path: '/kids', changefreq: 'monthly', priority: 0.7, title: 'Baby and kids shoe fit guide', text: 'Foot measurement, manufacturer size charts and an optional remeasure reminder.' },
+ { path: '/kids/measure', changefreq: 'monthly', priority: 0.6, title: 'Printable foot ruler', text: 'A calibration-checked printable 10 cm ruler and how-to guide.' },
{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The Shoes API', text: 'The open JSON API for shoes, offers, history, curves, drops and watches, and the OpenAPI document.' },
{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The Shoes API', text: 'The open JSON API for shoes, offers, history, curves, drops and watches, and the OpenAPI document.' },
];
];
for (const m of models) {
for (const m of models) {
@@ -102,6 +108,9 @@ function createDiscoveryRoutes(ctx) {
@@ -102,6 +108,9 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Search shoes', url: `${base()}/api/v1/shoes?q=air+max` },
{ title: 'Search shoes', url: `${base()}/api/v1/shoes?q=air+max` },
{ title: 'One shoe', url: `${base()}/api/v1/shoes/nike-air-max-90` },
{ title: 'One shoe', url: `${base()}/api/v1/shoes/nike-air-max-90` },
{ title: 'Drops', url: `${base()}/api/v1/drops` },
{ title: 'Drops', url: `${base()}/api/v1/drops` },
+ { title: 'Products', url: `${base()}/api/v1/products?kind=shoe` },
+ { title: 'Lace length tool', url: `${base()}/api/v1/tools/lace-length?eyelet_pairs=6` },
+ { title: 'Size conversion', url: `${base()}/api/v1/sizes/convert?brand=darn-tough&system=women&size=8` },
{ title: 'OpenAPI', url: `${base()}/api/openapi.json` },
{ title: 'OpenAPI', url: `${base()}/api/openapi.json` },
] },
] },
],
],
@@ -19,6 +19,7 @@ function openapi(config = {}) {
@@ -19,6 +19,7 @@ function openapi(config = {}) {
slug: { name: 'slug', in: 'path', required: true, schema: { type: 'string', maxLength: 80 } },
slug: { name: 'slug', in: 'path', required: true, schema: { type: 'string', maxLength: 80 } },
from: { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } },
from: { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } },
to: { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } },
to: { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } },
+ kind: { name: 'kind', in: 'query', schema: { type: 'string', enum: ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids'] } },
};
};
const ok = (schema) => ({ 200: { description: 'OK', content: { 'application/json': { schema } } } });
const ok = (schema) => ({ 200: { description: 'OK', content: { 'application/json': { schema } } } });
const bad = { 400: { description: 'Bad request', content: { 'application/problem+json': { schema: problem } } }, 404: { description: 'Not found', content: { 'application/problem+json': { schema: problem } } } };
const bad = { 400: { description: 'Bad request', content: { 'application/problem+json': { schema: problem } } }, 404: { description: 'Not found', content: { 'application/problem+json': { schema: problem } } } };
@@ -32,8 +33,23 @@ function openapi(config = {}) {
@@ -32,8 +33,23 @@ function openapi(config = {}) {
license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },
license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },
},
},
servers: [{ url: base }, { url: 'https://open.shoes' }],
servers: [{ url: base }, { url: 'https://open.shoes' }],
- tags: [{ name: 'shoes' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],
+ tags: [{ name: 'shoes' }, { name: 'products' }, { name: 'tools' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],
paths: {
paths: {
+ '/api/v1/products': { get: { tags: ['products'], summary: 'Search the shared product catalog', operationId: 'listProducts', parameters: [params.kind, params.q, params.size], responses: { ...ok({ type: 'object', properties: { products: { type: 'array', items: { type: 'object' } }, total: { type: 'integer' }, source_note: { type: ['string', 'null'] } } }), ...bad } } },
+ '/api/v1/products/{id}': { get: { tags: ['products'], summary: 'Get one product with cited facts', operationId: 'getProduct', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { ...ok({ type: 'object', properties: { product: { type: 'object' } } }), ...bad } } },
+ '/api/v1/products/{id}/offers': { get: { tags: ['products'], summary: 'Get offers for one product', operationId: 'getProductOffers', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { ...ok({ type: 'object', properties: { product_id: { type: 'string' }, offers: { type: 'array', items: { type: 'object' } } } }), ...bad } } },
+ '/api/v1/tools/lace-length': { get: { tags: ['tools'], summary: 'Estimate lace length from eyelet layout and lacing method', operationId: 'laceLength', parameters: [
+ { name: 'eyelet_pairs', in: 'query', required: true, schema: { type: 'integer', minimum: 2, maximum: 16 } },
+ { name: 'method', in: 'query', schema: { type: 'string', enum: ['criss-cross', 'bow-tie'] } },
+ { name: 'horizontal_cm', in: 'query', schema: { type: 'number', minimum: 0.5, maximum: 12, default: 3.5 } },
+ { name: 'vertical_cm', in: 'query', schema: { type: 'number', minimum: 0.5, maximum: 12, default: 2.5 } },
+ { name: 'ends_cm', in: 'query', schema: { type: 'number', minimum: 5, maximum: 80, default: 25 } },
+ ], responses: { ...ok({ type: 'object', properties: { cm: { type: 'number' }, inches: { type: 'number' }, method: { type: 'string' }, source: { type: 'object' } } }), ...bad } } },
+ '/api/v1/sizes/convert': { get: { tags: ['products'], summary: 'Convert a supported brand shoe size to its sock size', operationId: 'convertSize', parameters: [
+ { name: 'brand', in: 'query', schema: { type: 'string', enum: ['darn-tough'], default: 'darn-tough' } },
+ { name: 'system', in: 'query', required: true, schema: { type: 'string', enum: ['men', 'women', 'kids'] } },
+ { name: 'size', in: 'query', required: true, schema: { type: 'number' } },
+ ], responses: { ...ok({ type: 'object', properties: { conversions: { type: 'array', items: { type: 'object' } }, source: { type: 'object' } } }), ...bad } } },
'/api/v1/shoes': {
'/api/v1/shoes': {
get: {
get: {
tags: ['shoes'], summary: 'Search the catalogue', operationId: 'listShoes',
tags: ['shoes'], summary: 'Search the catalogue', operationId: 'listShoes',
@@ -21,6 +21,7 @@ const { sameOrigin } = require('./principal');
@@ -21,6 +21,7 @@ const { sameOrigin } = require('./principal');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views');
const views = require('../views');
const discovery = require('./discovery');
const discovery = require('./discovery');
+const productCatalog = require('../product-catalog');
// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from
// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from
// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.
// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.
@@ -94,6 +95,51 @@ function createPageRoutes(ctx) {
@@ -94,6 +95,51 @@ function createPageRoutes(ctx) {
});
});
});
});
+ const expansionFaq = (name, text) => [{ '@type': 'FAQPage', mainEntity: [{ '@type': 'Question', name, acceptedAnswer: { '@type': 'Answer', text } }] }];
+ r.get('/products', (req, res) => res.redirect(302, `/api/v1/products${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));
+ for (const [route, kind, title, description] of [
+ ['/laces', 'laces', 'Shoelaces and lacing guides', 'Estimate lace length from eyelet spacing and lacing method, then follow simple lacing diagrams.'],
+ ['/socks', 'socks', 'Sock sizing by shoe size', 'Use the sock maker’s size chart for the correct shoe-size range, material and use.'],
+ ['/care', 'care', 'Shoe care by upper material', 'Manufacturer-sourced shoe-care notes for leather, suede, knit, canvas, mesh and nubuck.'],
+ ['/kids', 'kids', 'Baby and kids shoe fit guide', 'Measure growing feet, consult brand charts and set an optional remeasure reminder.'],
+ ]) r.get(route, (req, res) => page(req, res, {
+ title, description, body: views.expansionPage({ kind, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }), active: route,
+ index: true, jsonLd: expansionFaq(`How do I use ${title.toLowerCase()}?`, description),
+ }));
+
+ r.get('/lace-length', (req, res) => {
+ const result = req.query.eyelet_pairs ? productCatalog.laceLength({
+ eyeletPairs: Number(req.query.eyelet_pairs), method: String(req.query.method || 'criss-cross'),
+ horizontalCm: req.query.horizontal_cm === undefined ? 3.5 : Number(req.query.horizontal_cm),
+ verticalCm: req.query.vertical_cm === undefined ? 2.5 : Number(req.query.vertical_cm),
+ endsCm: req.query.ends_cm === undefined ? 25 : Number(req.query.ends_cm),
+ }) : null;
+ return page(req, res, { title: 'Lace length finder', description: 'Estimate lace length using eyelet count, spacing and method.', body: views.expansionPage({ kind: 'laces', result }), active: '/laces', index: true,
+ jsonLd: [{ '@type': 'HowTo', name: 'Estimate shoelace length', step: ['Count eyelet pairs on one shoe.', 'Measure horizontal and vertical spacing in centimeters.', 'Choose a lacing pattern and tie-end length.', 'Compare the rounded estimate with your current lace.'].map((text) => ({ '@type': 'HowToStep', text })) }, ...expansionFaq('Is this lace length exact?', 'No. It is an estimate from your measurements; compare it with a lace that fits your shoe.') ] });
+ });
+
+ r.get('/kids/measure', (req, res) => page(req, res, { title: 'Printable foot measuring ruler', description: 'Print at actual size, verify the 10 cm calibration line, and measure each foot.', body: views.expansionPage({ kind: 'measure' }), active: '/kids', index: true,
+ jsonLd: [{ '@type': 'HowTo', name: 'Measure a child’s foot at home', step: ['Print at 100% / Actual Size.', 'Verify the 10 cm calibration mark.', 'Measure the longest toe while the child is standing.', 'Use the larger foot measurement with the chosen brand’s chart.'].map((text) => ({ '@type': 'HowToStep', text })) }] }));
+
+ r.post('/kids/growth-check', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fkids');
+ if (!guardOrigin(req, res)) return;
+ const nickname = typeof req.body.nickname === 'string' ? req.body.nickname.trim().slice(0, 40) : '';
+ const size = typeof req.body.size === 'string' ? req.body.size.trim().slice(0, 16) : '';
+ const stage = req.body.stage === 'toddler' ? 'toddler' : req.body.stage === 'child' ? 'child' : '';
+ if (req.body.consent !== 'yes' || !size || !stage) return res.redirect(302, `/kids?notice=${encodeURIComponent('Choose a current size and stage, then opt in to add a reminder.')}`);
+ const due = new Date();
+ due.setUTCDate(1);
+ due.setUTCMonth(due.getUTCMonth() + (stage === 'toddler' ? 3 : 6));
+ const days = new Date(Date.UTC(due.getUTCFullYear(), due.getUTCMonth() + 1, 0)).getUTCDate();
+ due.setUTCDate(Math.min(new Date().getUTCDate(), days));
+ due.setUTCHours(9, 0, 0, 0);
+ const display = nickname ? `${nickname}: ` : '';
+ const out = await upstreams.createTodayEvent({ kind: 'event', title: `Remeasure ${display}kids’ feet`.slice(0, 120), description: `Current shoe size: ${size}. Recheck both feet and compare with the shoe maker’s chart. This reminder contains no birthday.`, starts_at: due.toISOString(), tz: 'UTC', visibility: 'private' }, { token: req.viewer.token });
+ const notice = out.ok ? `Private reminder added for ${due.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}.` : `Today could not add the reminder: ${out.detail}`;
+ return res.redirect(302, `/kids?notice=${encodeURIComponent(notice)}`);
+ });
+
r.get('/shoe/:slug', async (req, res) => {
r.get('/shoe/:slug', async (req, res) => {
const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));
const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));
if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);
if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);
@@ -8,8 +8,8 @@ const { createOffersJob } = require('./offers');
@@ -8,8 +8,8 @@ const { createOffersJob } = require('./offers');
const { createDropsJob } = require('./drops');
const { createDropsJob } = require('./drops');
const { createEbayJob } = require('./ebay');
const { createEbayJob } = require('./ebay');
-function createJobs({ config, catalogue, upstreams, log = console }) {
- const offers = createOffersJob({ config, catalogue, upstreams, log });
+function createJobs({ config, catalogue, products, upstreams, log = console }) {
+ const offers = createOffersJob({ config, catalogue, products, upstreams, log });
const drops = createDropsJob({ config, catalogue, upstreams, log });
const drops = createDropsJob({ config, catalogue, upstreams, log });
const ebay = createEbayJob({ config, catalogue, upstreams, log });
const ebay = createEbayJob({ config, catalogue, upstreams, log });
const all = [offers, drops, ebay];
const all = [offers, drops, ebay];
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
*/
*/
const parse = require('../domain/parse');
const parse = require('../domain/parse');
-function createOffersJob({ config, catalogue, upstreams, log = console }) {
+function createOffersJob({ config, catalogue, products, upstreams, log = console }) {
let timer = null;
let timer = null;
let running = false;
let running = false;
@@ -21,14 +21,24 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
@@ -21,14 +21,24 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
return parse.isShoe(offer && offer.title, productName);
return parse.isShoe(offer && offer.title, productName);
}
}
+ function productKind(offer) {
+ const text = `${offer && offer.title || ''} ${offer && offer.product && offer.product.name || ''}`.toLowerCase();
+ if (/\b(shoelaces?|laces)\b/.test(text)) return 'laces';
+ if (/\b(socks?|compression socks?)\b/.test(text)) return 'socks';
+ if (/\b(insoles?|orthotic inserts?)\b/.test(text)) return 'insoles';
+ if (/\b(shoe care|shoe cleaner|sneaker cleaner|shoe brush|cleaning kit|shoe protector|waterproofing spray)\b/.test(text)) return 'care';
+ if (shoeCandidate(offer)) return /\b(baby|toddler|kids?|children['’]?s)\b/.test(text) ? 'kids' : 'shoe';
+ return null;
+ }
+
async function runOnce({ pages = config.sources.offersPages, maxProductLookups = config.sources.productLookups } = {}) {
async function runOnce({ pages = config.sources.offersPages, maxProductLookups = config.sources.productLookups } = {}) {
const runId = await catalogue.startJobRun({
const runId = await catalogue.startJobRun({
job: 'offers',
job: 'offers',
sourceName: 'OpenVibe.Deals',
sourceName: 'OpenVibe.Deals',
sourceUrl: `${config.sources.dealsUrl}/api/v1/offers`,
sourceUrl: `${config.sources.dealsUrl}/api/v1/offers`,
});
});
- const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, skipped: 0 };
- const brands = new Map();
+ const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, products: 0, accessory_offers: 0, skipped: 0 };
+ const productsSeen = new Map();
try {
try {
const total = Math.max(1, Math.min(200, Number(pages) || 1));
const total = Math.max(1, Math.min(200, Number(pages) || 1));
for (let page = 1; page <= total; page++) {
for (let page = 1; page <= total; page++) {
@@ -37,22 +47,44 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
@@ -37,22 +47,44 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
counts.pages++;
counts.pages++;
counts.seen += offers.length;
counts.seen += offers.length;
for (const offer of offers) {
for (const offer of offers) {
- if (!shoeCandidate(offer)) continue;
+ const kind = productKind(offer);
+ if (!kind) continue;
counts.candidates++;
counts.candidates++;
const observation = offer.latest_observation || {};
const observation = offer.latest_observation || {};
const product = offer.product || {};
const product = offer.product || {};
- let brand = null;
+ let sourceProduct = null;
if (product.slug && counts.product_lookups < maxProductLookups) {
if (product.slug && counts.product_lookups < maxProductLookups) {
- if (brands.has(product.slug)) brand = brands.get(product.slug);
+ if (productsSeen.has(product.slug)) sourceProduct = productsSeen.get(product.slug);
else {
else {
const p = await upstreams.dealsProduct(product.slug);
const p = await upstreams.dealsProduct(product.slug);
counts.product_lookups++;
counts.product_lookups++;
- brand = p && p.brand ? p.brand : null;
- brands.set(product.slug, brand);
+ sourceProduct = p || null;
+ productsSeen.set(product.slug, sourceProduct);
}
}
}
}
- const modelName = product.name || offer.title;
+ const brand = sourceProduct && sourceProduct.brand ? sourceProduct.brand : null;
+ const modelName = sourceProduct && sourceProduct.name || product.name || offer.title;
const checkedAt = observation.observed_at || observation.recorded_at || new Date().toISOString();
const checkedAt = observation.observed_at || observation.recorded_at || new Date().toISOString();
+ if (kind !== 'shoe') {
+ if (!products || !product.slug || !sourceProduct || !offer.link && !offer.url) { counts.skipped++; continue; }
+ const slug = String(product.slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 150);
+ const catalogProduct = await products.upsert({
+ kind, slug, title: modelName, brand, model: modelName, url: `https://openvibe.deals/products/${encodeURIComponent(product.slug)}`,
+ sizes: [parse.sizeOf(offer.title)].filter(Boolean), sourceName: 'OpenVibe.Deals',
+ sourceUrl: `https://openvibe.deals/api/v1/products/${encodeURIComponent(product.slug)}`, checkedAt, facts: [],
+ });
+ const item = await products.upsertOffer({
+ productSlug: catalogProduct.id, source: 'deals', sourceId: offer.id, storeDomain: offer.store && offer.store.domain,
+ storeName: offer.store && offer.store.name, title: offer.title, url: offer.link || offer.url,
+ price: observation.price, shipping: observation.shipping, currency: observation.currency || 'USD',
+ size: parse.sizeOf(offer.title), condition: observation.condition, checkedAt, raw: { id: offer.id, product: product.slug },
+ });
+ if (catalogProduct) counts.products++;
+ if (item) counts.accessory_offers++;
+ else counts.skipped++;
+ if (config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs / 2);
+ continue;
+ }
const row = await catalogue.upsertOffer({
const row = await catalogue.upsertOffer({
source: 'deals',
source: 'deals',
sourceId: offer.id,
sourceId: offer.id,
@@ -82,7 +114,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
@@ -82,7 +114,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
if (page < total && config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs);
if (page < total && config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs);
}
}
await catalogue.finishJobRun(runId, { ok: true, counts });
await catalogue.finishJobRun(runId, { ok: true, counts });
- log.log(`[Open.Shoes] offers: ${counts.offers} offers from ${counts.candidates} shoe candidates over ${counts.pages} pages`);
+ log.log(`[Open.Shoes] offers: ${counts.offers} shoe offers and ${counts.accessory_offers} product offers from ${counts.candidates} candidates over ${counts.pages} pages`);
return counts;
return counts;
} catch (err) {
} catch (err) {
await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });
await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });
@@ -107,7 +139,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
@@ -107,7 +139,7 @@ function createOffersJob({ config, catalogue, upstreams, log = console }) {
timer = null;
timer = null;
}
}
- return { name: 'offers', runOnce, start, stop, shoeCandidate };
+ return { name: 'offers', runOnce, start, stop, shoeCandidate, productKind };
}
}
module.exports = { createOffersJob };
module.exports = { createOffersJob };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+const KINDS = ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids'];
+const CATEGORIES = [
+ { kind: 'shoe', title: 'Shoes', href: '/shoes', description: 'Compare all-in prices by model and size.' },
+ { kind: 'laces', title: 'Laces', href: '/laces', description: 'Find a starting length and explore lacing methods.' },
+ { kind: 'socks', title: 'Socks', href: '/socks', description: 'Use each sock brand’s own shoe-size chart.' },
+ { kind: 'care', title: 'Shoe care', href: '/care', description: 'Care notes by upper material, with manufacturer sources.' },
+ { kind: 'insoles', title: 'Insoles', href: '/products?kind=insoles', description: 'Compatibility and prices will appear when verified sources are available.' },
+ { kind: 'kids', title: 'Kids & baby', href: '/kids', description: 'Measure first, then compare manufacturer size charts.' },
+];
+
+const METHODS = {
+ 'criss-cross': 'Criss Cross',
+ 'bow-tie': 'Bow Tie / Army',
+};
+
+function laceLength({ eyeletPairs, method = 'criss-cross', horizontalCm = 3.5, verticalCm = 2.5, endsCm = 25 } = {}) {
+ const p = Number(eyeletPairs), h = Number(horizontalCm), v = Number(verticalCm), l = Number(endsCm);
+ if (!Number.isInteger(p) || p < 2 || p > 16 || !Number.isFinite(h) || h < 0.5 || h > 12 || !Number.isFinite(v) || v < 0.5 || v > 12 || !Number.isFinite(l) || l < 5 || l > 80) return null;
+ let cm;
+ if (method === 'criss-cross') cm = 2 * (h + (p - 1) * Math.hypot(h, v) + l);
+ else if (method === 'bow-tie') cm = 2 * (h + v * Math.floor(p / 2) + Math.hypot(h, v) * Math.floor((p - 1) / 2) + l);
+ else return null;
+ return { method, method_name: METHODS[method], eyelet_pairs: p, cm: Math.ceil(cm / 5) * 5, inches: Math.ceil((cm / 2.54) / 2) * 2, assumptions: { horizontal_cm: h, vertical_cm: v, ends_cm: l }, source: { name: "Ian's Shoelace Site", url: 'https://www.fieggen.com/shoelace/lengthformulas.htm' } };
+}
+
+const SOCK_CHARTS = {
+ men: [ [3, 5, 'S'], [5.5, 7.5, 'M'], [8, 9.5, 'L'], [10, 12, 'XL'] ],
+ women: [ [4.5, 7, 'S'], [7.5, 9.5, 'M'], [10, 11.5, 'L'], [12, 14, 'XL'] ],
+ kids: [ [9, 11.5, 'S'], [12, 14.5, 'M'], [3, 6, 'L'] ],
+};
+
+function sizeConvert({ system, size, brand = 'darn-tough' } = {}) {
+ const value = Number(size);
+ if (!Number.isFinite(value) || !system) return null;
+ if (brand === 'darn-tough' && ['men', 'women', 'kids'].includes(system)) {
+ let range;
+ if (system === 'kids') {
+ const label = value >= 9 && value <= 11.5 ? 'S' : ([12, 13, 1, 1.5, 2, 2.5].includes(value) ? 'M' : value >= 3 && value <= 6 ? 'L' : null);
+ range = label ? [0, 0, label] : null;
+ } else range = SOCK_CHARTS[system].find(([min, max]) => value >= min && value <= max);
+ return { brand, system, input_size: String(size), conversions: range ? [{ system: 'sock', size: range[2] }] : [], source: { name: 'Darn Tough sock size chart', url: 'https://darntough.com/pages/size-chart' } };
+ }
+ return null;
+}
+
+function asProduct(model, facts = []) {
+ return {
+ id: model.slug, kind: 'shoe', title: `${model.brand} ${model.model}`, brand: model.brand, model: model.model,
+ url: `/shoe/${encodeURIComponent(model.slug)}`, image: null, sizes: [], materials: [], uses: [],
+ source: { name: 'OpenVibe.Deals', url: 'https://openvibe.deals' }, checked_at: model.first_seen || new Date(0).toISOString(),
+ facts: facts.map((fact) => ({ name: fact.label, value: fact.value, source: { name: fact.source_name, url: fact.source_url } })),
+ };
+}
+
+module.exports = { KINDS, CATEGORIES, laceLength, sizeConvert, asProduct };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+const { KINDS } = require('./product-catalog');
+
+function json(value, fallback = []) {
+ if (Array.isArray(value)) return value;
+ if (typeof value === 'string') { try { const out = JSON.parse(value); return Array.isArray(out) ? out : fallback; } catch { return fallback; } }
+ return fallback;
+}
+
+function wireProduct(row) {
+ return {
+ id: row.slug, kind: row.kind, title: row.title, brand: row.brand || null, model: row.model || null,
+ url: row.url || null, image: row.image || null, sizes: json(row.sizes), materials: json(row.materials), uses: json(row.uses),
+ source: { name: row.source_name, url: row.source_url }, checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : String(row.checked_at), facts: json(row.facts),
+ };
+}
+
+function wireOffer(row) {
+ const price = row.price_amount == null ? null : Number(row.price_amount);
+ const shipping = row.shipping_amount == null ? null : Number(row.shipping_amount);
+ return {
+ id: row.id, product_id: row.product_slug, store: row.store_domain, store_name: row.store_name || null,
+ title: row.title, url: row.url, price, shipping, shipping_known: shipping !== null,
+ all_in: price == null ? null : Math.round((price + (shipping || 0)) * 100) / 100,
+ currency: row.currency, size: row.size || null, condition: row.condition || null,
+ checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : String(row.checked_at), stale: row.stale === true, source: row.source,
+ };
+}
+
+function createProductStore(store) {
+ const { db } = store;
+ return {
+ async list({ kind = null, q = null, size = null, limit = 100, offset = 0 } = {}) {
+ const rows = await db.many(`SELECT * FROM shoe_products
+ WHERE ($1::text IS NULL OR kind = $1)
+ AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' OR brand ILIKE '%' || $2 || '%')
+ AND ($3::text IS NULL OR sizes ? $3)
+ ORDER BY lower(title), slug LIMIT $4 OFFSET $5`, [kind, q, size, limit, offset]);
+ return rows.map(wireProduct);
+ },
+ async bySlug(slug) {
+ const row = await db.maybe('SELECT * FROM shoe_products WHERE slug = $1 LIMIT 1', [slug]);
+ return row ? wireProduct(row) : null;
+ },
+ async offers(slug) {
+ const rows = await db.many(`SELECT o.*, p.slug AS product_slug FROM shoe_product_offers o
+ JOIN shoe_products p ON p.id = o.product_id WHERE p.slug = $1 ORDER BY o.checked_at DESC`, [slug]);
+ return rows.map(wireOffer);
+ },
+ async upsert(input) {
+ if (!input || !KINDS.includes(input.kind) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(input.slug || ''))
+ || !input.title || !input.sourceName || !/^https?:\/\//.test(String(input.sourceUrl || ''))) throw new TypeError('product kind, slug, title and cited source are required');
+ const id = `sp_${input.slug}`;
+ await db.exec(`INSERT INTO shoe_products (id, kind, slug, title, brand, model, url, image, sizes, materials, uses, source_name, source_url, checked_at, facts)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb)
+ ON CONFLICT (slug) DO UPDATE SET kind=EXCLUDED.kind,title=EXCLUDED.title,brand=EXCLUDED.brand,model=EXCLUDED.model,
+ url=EXCLUDED.url,image=EXCLUDED.image,sizes=EXCLUDED.sizes,materials=EXCLUDED.materials,uses=EXCLUDED.uses,
+ source_name=EXCLUDED.source_name,source_url=EXCLUDED.source_url,checked_at=EXCLUDED.checked_at,facts=EXCLUDED.facts,updated_at=now()`,
+ [id, input.kind, input.slug, input.title, input.brand || null, input.model || null, input.url || null, input.image || null,
+ JSON.stringify(input.sizes || []), JSON.stringify(input.materials || []), JSON.stringify(input.uses || []), input.sourceName, input.sourceUrl,
+ input.checkedAt || store.iso(), JSON.stringify(input.facts || [])]);
+ return this.bySlug(input.slug);
+ },
+ async upsertOffer(input) {
+ if (!input || !input.productSlug || !input.source || !input.sourceId || !/^https?:\/\//.test(String(input.url || ''))) throw new TypeError('product, source id and http(s) offer URL are required');
+ const product = await db.maybe('SELECT id FROM shoe_products WHERE slug=$1', [input.productSlug]);
+ if (!product) throw new Error('product not found');
+ const id = store.newId('spo');
+ const saved = await db.one(`INSERT INTO shoe_product_offers (id,product_id,source,source_id,store_domain,store_name,title,url,price_amount,shipping_amount,currency,size,condition,checked_at,stale,raw)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)
+ ON CONFLICT (source,source_id) DO UPDATE SET store_domain=EXCLUDED.store_domain,store_name=EXCLUDED.store_name,title=EXCLUDED.title,url=EXCLUDED.url,
+ price_amount=EXCLUDED.price_amount,shipping_amount=EXCLUDED.shipping_amount,currency=EXCLUDED.currency,size=EXCLUDED.size,condition=EXCLUDED.condition,checked_at=EXCLUDED.checked_at,stale=EXCLUDED.stale,raw=EXCLUDED.raw RETURNING id`,
+ [id, product.id, input.source, input.sourceId, input.storeDomain, input.storeName || null, input.title, input.url, input.price ?? null, input.shipping ?? null,
+ input.currency || 'USD', input.size || null, input.condition || null, input.checkedAt || store.iso(), Boolean(input.stale), JSON.stringify(input.raw || {})]);
+ return (await this.offers(input.productSlug)).find((offer) => offer.id === saved.id) || null;
+ },
+ };
+}
+
+module.exports = { createProductStore, wireProduct, wireOffer };
@@ -246,6 +246,10 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curve
@@ -246,6 +246,10 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curve
explore: {
explore: {
title: 'More on Shoes',
title: 'More on Shoes',
items: [
items: [
+ { title: 'Laces & length finder', text: 'Measure spacing and choose a lacing method for an estimate in cm and inches', href: '/laces', icon: 'ov:link' },
+ { title: 'Socks by shoe size', text: 'Check a brand’s own chart, with use and material guidance', href: '/socks', icon: 'ov:shoe' },
+ { title: 'Care by material', text: 'Manufacturer-sourced notes for leather, suede, knit, canvas, mesh and nubuck', href: '/care', icon: 'ov:heart' },
+ { title: 'Kids & baby fit', text: 'Measure their feet, open a printable ruler and set a private growth reminder', href: '/kids', icon: 'ov:shoe' },
{ title: 'Every shoe', text: 'Search by brand, model, size and condition; every price is all-in', href: '/shoes', icon: 'ov:search' },
{ title: 'Every shoe', text: 'Search by brand, model, size and condition; every price is all-in', href: '/shoes', icon: 'ov:search' },
{ title: 'The drops calendar', text: 'Official release dates, each cited to the brand\'s or store\'s own page', href: '/drops', icon: 'ov:calendar' },
{ title: 'The drops calendar', text: 'Official release dates, each cited to the brand\'s or store\'s own page', href: '/drops', icon: 'ov:calendar' },
{ title: 'Your watches', text: 'The price watches and pasted links that OpenVibe.Watch carries', href: '/watches', icon: 'ov:bell' },
{ title: 'Your watches', text: 'The price watches and pasted links that OpenVibe.Watch carries', href: '/watches', icon: 'ov:bell' },
@@ -283,16 +287,40 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curve
@@ -283,16 +287,40 @@ function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curve
],
],
},
},
});
});
- // The price-drop section is a real rail when there are drops and an honest note when there are none; it sits
- // directly under the hero, before the "dropping this week" and "popular models" rails.
- const railAt = body.indexOf('<section class="ovk-rail');
- const sceneAt = body.indexOf('<section class="sc-room-scenes"');
- const at = railAt >= 0 ? railAt : sceneAt;
+ // Category tiles follow the hero; the price-drop rail precedes the other model/release rails.
+ const categories = `<section class="sh-home-categories" aria-labelledby="sh-home-categories-h"><h2 id="sh-home-categories-h">More than shoes</h2><div>${[
+ ['Laces', 'Length finder and lacing guides', '/laces'], ['Socks', 'Brand size charts by shoe size', '/socks'],
+ ['Care', 'Guides by upper material', '/care'], ['Kids & baby', 'Measure, chart and remeasure', '/kids'],
+ // Insoles come back as a tile when they have product matches (a tile that led to an empty JSON list was a dead end).
+ ].map(([title, line, href]) => `<a href="${href}"><b>${title}</b><span>${line}</span><i aria-hidden="true">→</i></a>`).join('')}</div><p><a href="/lace-length">Lace length tool</a><span aria-hidden="true"> · </span><a href="/kids/measure">Print the foot ruler</a></p></section>`;
+ const heroEnd = body.indexOf('</section>');
+ const homeWithCategories = heroEnd >= 0 ? `${body.slice(0, heroEnd + 10)}${categories}${body.slice(heroEnd + 10)}` : `${categories}${body}`;
// The home sits in the page's width like every other page (it ran edge to edge, 10-31).
// The home sits in the page's width like every other page (it ran edge to edge, 10-31).
- const page = at < 0 ? `${body}${priceDropSection}` : `${body.slice(0, at)}${priceDropSection}${body.slice(at)}`;
+ const railAtIn = homeWithCategories.indexOf('<section class="ovk-rail');
+ const sceneAtIn = homeWithCategories.indexOf('<section class="sc-room-scenes"');
+ const homeAt = railAtIn >= 0 ? railAtIn : sceneAtIn;
+ const page = homeAt < 0 ? `${homeWithCategories}${priceDropSection}` : `${homeWithCategories.slice(0, homeAt)}${priceDropSection}${homeWithCategories.slice(homeAt)}`;
return `<div class="sh-wrap sh-home">${page}</div>`;
return `<div class="sh-wrap sh-home">${page}</div>`;
}
}
+function laceDiagram(name, reverse = false) {
+ const path = reverse ? 'M20 15 L100 55 M20 55 L100 15 M20 35 L100 35' : 'M20 15 L100 55 M100 15 L20 55 M20 35 L100 75 M100 35 L20 75';
+ return `<svg class="sh-lace-diagram" viewBox="0 0 120 90" role="img" aria-label="${esc(name)} lacing diagram"><path d="${path}"/><circle cx="20" cy="15" r="3"/><circle cx="100" cy="15" r="3"/><circle cx="20" cy="35" r="3"/><circle cx="100" cy="35" r="3"/><circle cx="20" cy="55" r="3"/><circle cx="100" cy="55" r="3"/><circle cx="20" cy="75" r="3"/><circle cx="100" cy="75" r="3"/></svg>`;
+}
+
+function expansionPage({ kind, result, notice, signedIn = false } = {}) {
+ const escLink = (href, label) => `<a href="${esc(href)}" rel="noopener">${esc(label)}</a>`;
+ if (kind === 'laces') return `<main class="sh-exp"><p class="sh-kicker">Laces · tools</p><h1>Find a lace length that fits your lacing</h1><p>Eyelet count is a starting point. Measure across the shoe, between eyelet rows, and allow for the ends you want to tie. Different methods use different amounts of lace.</p><form class="sh-exp-form" action="/lace-length" method="get"><label>Eyelet pairs <input type="number" min="2" max="16" name="eyelet_pairs" value="${esc(result?.eyelet_pairs || 6)}" required></label><label>Lacing method <select name="method"><option value="criss-cross"${result?.method === 'criss-cross' ? ' selected' : ''}>Criss-cross</option><option value="bow-tie"${result?.method === 'bow-tie' ? ' selected' : ''}>Bow-tie / Army</option></select></label><label>Across tongue (cm) <input type="number" min="0.5" max="12" step="0.1" name="horizontal_cm" value="${esc(result?.assumptions?.horizontal_cm || 3.5)}"></label><label>Between rows (cm) <input type="number" min="0.5" max="12" step="0.1" name="vertical_cm" value="${esc(result?.assumptions?.vertical_cm || 2.5)}"></label><label>Each tie end (cm) <input type="number" min="5" max="80" step="1" name="ends_cm" value="${esc(result?.assumptions?.ends_cm || 25)}"></label><button class="sh-btn" type="submit">Estimate length</button></form>${result ? `<section class="sh-exp-result" aria-live="polite"><h2>Starting estimate</h2><p><strong>${result.cm} cm</strong> · ${result.inches} in · ${result.eyelet_pairs} eyelet pairs · ${esc(result.method_name)}</p><p>Rounded up for common retail lengths. Compare with a lace that fits now; width, eyelet position and knot preference change the result.</p></section>` : ''}<h2>Two lacing methods</h2><div class="sh-exp-grid"><article>${laceDiagram('Criss-cross lacing')}<h3>Criss-cross</h3><p>Alternating diagonals distribute tension and use a standard crossed path.</p></article><article>${laceDiagram('Bow-tie lacing', true)}<h3>Bow-tie / Army</h3><p>Straight across the shoe, with vertical sections between rows.</p></article></div><p>Formula reference: ${escLink('https://www.fieggen.com/shoelace/lengthformulas.htm', "Ian's Shoelace Site length formulas")}. Estimates use your five measurements and are not a guaranteed fit.</p><h2>Frequently asked</h2><details><summary>Can eyelet count alone tell me the right length?</summary><p>No. Width, row spacing, lacing pattern and knot ends change the length.</p></details><details><summary>Does Open.Shoes sell or recommend a lace?</summary><p>We show offers only when the source identifies them. No verified lace products are configured yet.</p></details></main>`;
+ if (kind === 'socks') return `<main class="sh-exp"><p class="sh-kicker">Socks · size and use</p><h1>Start with your shoe size. Check the sock brand’s chart.</h1><p>Sock sizes vary by brand and intended fit. Darn Tough recommends using shoe size and sizing up between categories. We keep this conversion brand-specific.</p><form class="sh-exp-form" action="/api/v1/sizes/convert" method="get" target="_blank"><label>Chart <select name="brand"><option value="darn-tough">Darn Tough</option></select></label><label>Shoe size system <select name="system"><option value="men">US men</option><option value="women">US women</option><option value="kids">US kids</option></select></label><label>Shoe size <input name="size" type="number" step="0.5" min="1" max="20" required></label><button class="sh-btn">Find sock size</button></form><p>Source: ${escLink('https://darntough.com/pages/size-chart', 'Darn Tough sock sizing chart')} (checked 2026-11-09). Other sock brands: Bombas, Stance, Balega and Smartwool charts are not yet encoded; follow each manufacturer’s chart.</p><h2>Choose by use</h2><ul><li>Running: look for the brand’s running-specific cushioning and fit notes.</li><li>Hiking: compare crew height, cushioning and merino/fiber details.</li><li>Everyday: compare the maker’s everyday height and material options.</li><li>Compression: use the maker’s circumference chart; shoe size alone is not enough.</li></ul><p>Materials to compare: merino wool, synthetic blends, cotton and nylon. We don’t guess a product’s fiber content.</p><h2>Frequently asked</h2><details><summary>Are sock sizes the same as shoe sizes?</summary><p>No. A brand maps a range of shoe sizes to its own sock sizes.</p></details><details><summary>What if I am between two sock sizes?</summary><p>Check the manufacturer’s own recommendation. Darn Tough recommends sizing up.</p></details></main>`;
+ if (kind === 'care') return `<main class="sh-exp"><p class="sh-kicker">Shoe care · material guide</p><h1>Care for the upper material first</h1><p>Check the maker’s label and model page before cleaning. Mixed materials need the most delicate method present.</p><div class="sh-exp-grid"><article><h2>Leather</h2><p>Start with a soft dry cloth; for deeper cleaning, follow the shoe maker’s leather instructions. Product compatibility: not known yet.</p></article><article><h2>Suede & nubuck</h2><p>Brush dry and gently; avoid soaking. A maker may recommend a specific brush or professional cleaning. Product compatibility: not known yet.</p></article><article><h2>Knit & mesh</h2><p>Remove loose dirt before spot-cleaning. Check the exact model’s care label before using water or a cleaner.</p></article><article><h2>Canvas</h2><p>Use the maker’s method for that model; canvas blends and coatings vary. Avoid assuming every canvas shoe is machine washable.</p></article></div><p>Sources: ${escLink('https://www.clarks.com/en-us/editorial/how-to-clean-shoes', 'Clarks material care guide')}, ${escLink('https://www.stuartweitzman.com/content/sw-care-guide', 'Stuart Weitzman care guide')}. Recommendations may differ by finish and construction.</p><h2>Frequently asked</h2><details><summary>Can I put sneakers in a washing machine?</summary><p>Only if the shoe maker says that exact shoe is machine-safe. Leather, suede, glue and foam may be damaged.</p></details><details><summary>Which cleaner fits my shoe?</summary><p>Not known yet unless a manufacturer explicitly identifies a compatible product.</p></details></main>`;
+ if (kind === 'kids') return `<main class="sh-exp"><p class="sh-kicker">Baby & kids · fit tools</p><h1>Measure the foot. Age is only a rough label.</h1><p>Baby sizes may use month labels, but manufacturer guidance still recommends measuring. Once children move into numbered sizes, age does not predict shoe size.</p><p>Charts: ${escLink('https://www.striderite.com/pages/sizing-help', 'Stride Rite sizing and foot-length chart')} · ${escLink('https://seekairun.com/', 'See Kai Run brand home')} (use its current sizing chart) · ${escLink('https://www.nike.com/size-fit/kids-footwear', 'Nike Kids footwear chart')}.</p><h2>Stride Rite baby chart examples</h2><p>Brand-specific length guidance (not a universal conversion):</p><ul><li>US size 2 · 3–6 months · foot length 9.9 cm</li><li>US size 3 · 6–9 months · foot length 10.8 cm</li><li>US size 4 · 9–12 months · foot length 11.6 cm</li></ul><p>Read the full current chart for width and later sizes. The maker says age does not reliably map to size after baby shoes.</p><p><a class="sh-btn" href="/kids/measure">Open printable 28 cm ruler & measuring guide</a></p><h2>Ask for a private remeasure reminder</h2>${notice ? `<p class="sh-exp-notice">${esc(notice)}</p>` : ''}${signedIn ? `<form class="sh-exp-form" action="/kids/growth-check" method="post"><label>Nickname (optional) <input name="nickname" maxlength="40" autocomplete="off"></label><label>Current size <input name="size" maxlength="16" required></label><label>Stage <select name="stage"><option value="toddler">Toddler (remeasure in 3 months)</option><option value="child">Child (remeasure in 6 months)</option></select></label><label class="sh-exp-consent"><input type="checkbox" name="consent" value="yes" required> Add a private reminder to my OpenVibe.Today agenda. I understand this stores only the nickname and current shoe size with the reminder; it does not ask for a birthday.</label><button class="sh-btn">Add reminder to Today</button></form>` : '<p><a class="sh-btn" href="/auth/login?next=%2Fkids">Sign in to add a reminder</a></p>'}<h2>Measure again</h2><p>Recheck periodically and whenever shoes feel tight. One manufacturer recommends measuring every two months; the reminder intervals here are the opt-in defaults from this feature brief, not a medical or growth prediction.</p><h2>Frequently asked</h2><details><summary>Can age predict a child’s shoe size?</summary><p>No. Use foot length and that shoe brand’s chart.</p></details><details><summary>What personal details are stored?</summary><p>The Today reminder contains only the optional nickname, current shoe size and reminder date. No birthday is requested.</p></details></main>`;
+ if (kind === 'measure') {
+ const ticks = Array.from({ length: 29 }, (_, i) => { const x = 20 + i * 40; return `M${x} 10V${i % 5 === 0 ? 55 : 35}`; }).join(' ');
+ return `<main class="sh-exp sh-print"><p class="sh-kicker">Print · measure · compare</p><h1>Measure a foot at home</h1><ol><li>Print this page in landscape at 100% / Actual Size with “fit to page” turned off.</li><li>Check that the highlighted calibration segment measures exactly 10 cm. If it does not, do not use the ruler.</li><li>Stand with heel at zero, measure the longest toe, and repeat both feet while wearing usual socks.</li><li>Use the larger measurement with the brand’s current size chart. Foot length is not the same as shoe size.</li></ol><svg class="sh-ruler" viewBox="0 0 1160 80" role="img" aria-label="Printable 28 centimetre ruler with 10 centimetre calibration segment"><path d="${ticks} M20 55H1140 M20 62V68 M420 62V68 M820 62V68 M1140 62V68"/><path class="sh-ruler-calibration" d="M20 60H420"/><text x="20" y="78">0</text><text x="390" y="78">10 cm calibration check</text><text x="790" y="78">20</text><text x="1100" y="78">28 cm</text></svg><p>Ruler length: 28 cm. Calibration segment: 10 cm. Sources: ${escLink('https://www.striderite.com/pages/sizing-help', 'Stride Rite measurement guide')} and ${escLink('https://www.newbalance.co.uk/on/demandware.static/-/Library-Sites-NBUK-NBEU/default/v7f0d890e275d869ed497c2f4ed0e3ec92eb1e357/pdf/Kids_Printable_Size_Chart_2025.pdf', 'New Balance kids printable chart')} (PDF). Manufacturer charts are brand-specific.</p><button class="sh-btn" onclick="window.print()">Print this ruler</button></main>`;
+ }
+ return '';
+}
+
// ── Search ──────────────────────────────────────────────────
// ── Search ──────────────────────────────────────────────────
function shoesIndex({ models = [], facets = {}, query = {}, page = 1, total = 0, now = Date.now() } = {}) {
function shoesIndex({ models = [], facets = {}, query = {}, page = 1, total = 0, now = Date.now() } = {}) {
const perPage = 24;
const perPage = 24;
@@ -335,6 +363,7 @@ ${watchForm({ model, sizes, signedIn, next: `/shoe/${model.slug}` })}
@@ -335,6 +363,7 @@ ${watchForm({ model, sizes, signedIn, next: `/shoe/${model.slug}` })}
<section class="sh-sec" aria-labelledby="sh-colours-h"><h2 id="sh-colours-h">Colourways</h2>${colours.length ? `<ul class="sh-chips">${colours.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '<p class="sh-muted">Not known yet: no offer names a colourway.</p>'}</section>
<section class="sh-sec" aria-labelledby="sh-colours-h"><h2 id="sh-colours-h">Colourways</h2>${colours.length ? `<ul class="sh-chips">${colours.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '<p class="sh-muted">Not known yet: no offer names a colourway.</p>'}</section>
${releases.length ? `<section class="sh-sec" aria-labelledby="sh-releases-h"><h2 id="sh-releases-h">Release dates</h2><p class="sh-fine">From the brand's or the store's own release page, cited. Shoes never enters a raffle.</p><ul class="sh-list">${releases.map((d) => `<li><b>${esc(dayLong(d.release_at))}</b> · ${esc(d.colourway || 'colourway not stated')}${d.price != null ? ` · ${money(d.price, d.currency)}` : ''}${d.sizes.length ? ` · sizes ${d.sizes.slice(0, 12).map(esc).join(', ')}` : ''} · ${sourceLine(d.source_name, d.source_url)}${signedIn ? ` · <a href="/drops#drop-${encodeURIComponent(d.id)}">Remind me</a>` : ''}</li>`).join('')}</ul></section>` : ''}
${releases.length ? `<section class="sh-sec" aria-labelledby="sh-releases-h"><h2 id="sh-releases-h">Release dates</h2><p class="sh-fine">From the brand's or the store's own release page, cited. Shoes never enters a raffle.</p><ul class="sh-list">${releases.map((d) => `<li><b>${esc(dayLong(d.release_at))}</b> · ${esc(d.colourway || 'colourway not stated')}${d.price != null ? ` · ${money(d.price, d.currency)}` : ''}${d.sizes.length ? ` · sizes ${d.sizes.slice(0, 12).map(esc).join(', ')}` : ''} · ${sourceLine(d.source_name, d.source_url)}${signedIn ? ` · <a href="/drops#drop-${encodeURIComponent(d.id)}">Remind me</a>` : ''}</li>`).join('')}</ul></section>` : ''}
${factsSection(facts)}
${factsSection(facts)}
+<section class="sh-sec" aria-labelledby="sh-care-h"><h2 id="sh-care-h">Keep them clean</h2><p>Start with this shoe’s manufacturer care notes above. If the upper material is not known yet, check the maker’s product page before using water or cleaner.</p><p><a class="sh-btn sh-btn-quiet" href="/care">Open the material care guide</a></p></section>
<p class="sh-fine">The release facts and care notes above are the source's words, cited with the page and the date Shoes read it. The stores' own pages are the only place to buy; Shoes never takes an order.</p></div>`;
<p class="sh-fine">The release facts and care notes above are the source's words, cited with the page and the date Shoes read it. The stores' own pages are the only place to buy; Shoes never takes an order.</p></div>`;
return body;
return body;
}
}
@@ -438,4 +467,4 @@ function notFound() {
@@ -438,4 +467,4 @@ function notFound() {
return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;
return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;
}
}
-module.exports = { home, shoesIndex, shoe, drops, watches, watchPage, watchGate, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };
+module.exports = { home, shoesIndex, shoe, drops, watches, watchPage, watchGate, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable, expansionPage };
@@ -41,6 +41,24 @@ const DEALS = [
@@ -41,6 +41,24 @@ const DEALS = [
store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers' },
store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers' },
latest_observation: { price: '40.00', list_price: null, shipping: null, condition: null, currency: 'USD', observed_at: DAY },
latest_observation: { price: '40.00', list_price: null, shipping: null, condition: null, currency: 'USD', observed_at: DAY },
},
},
+ {
+ id: 'dof_test_socks', slug: 'hiker-micro-crew-socks',
+ title: "Darn Tough Hiker Micro Crew Socks Men's", link: 'https://darntough.com/products/hiker-micro-crew',
+ store: { domain: 'darntough.com', name: 'Darn Tough' }, product: { slug: 'hiker-micro-crew-socks', name: 'Hiker Micro Crew Socks' },
+ latest_observation: { price: '25.00', shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_laces', slug: 'flat-cotton-laces',
+ title: 'Flat Cotton Shoelaces 120 cm', link: 'https://www.lacelab.com/products/flat-cotton-laces',
+ store: { domain: 'lacelab.com', name: 'Lace Lab' }, product: { slug: 'flat-cotton-laces', name: 'Flat Cotton Shoelaces' },
+ latest_observation: { price: '8.00', shipping: '2.00', condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_cleaner', slug: 'sneaker-cleaning-kit',
+ title: 'Sneaker Cleaning Kit', link: 'https://jasonmarkk.com/products/essential-kit',
+ store: { domain: 'jasonmarkk.com', name: 'Jason Markk' }, product: { slug: 'sneaker-cleaning-kit', name: 'Sneaker Cleaning Kit' },
+ latest_observation: { price: '18.00', shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },
+ },
{
{
id: 'dof_test_990', slug: 'new-balance-990v6',
id: 'dof_test_990', slug: 'new-balance-990v6',
title: 'New Balance 990v6 Running Shoe', link: 'https://www.newbalance.com/pd/990v6',
title: 'New Balance 990v6 Running Shoe', link: 'https://www.newbalance.com/pd/990v6',
@@ -54,6 +72,9 @@ const PRODUCTS = {
@@ -54,6 +72,9 @@ const PRODUCTS = {
'air-jordan-1-mid': { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid', brand: 'Jordan' },
'air-jordan-1-mid': { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid', brand: 'Jordan' },
'samba-og': { slug: 'samba-og', name: 'Samba OG', brand: 'Adidas' },
'samba-og': { slug: 'samba-og', name: 'Samba OG', brand: 'Adidas' },
'rival-fleece-joggers': { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers', brand: 'Under Armour' },
'rival-fleece-joggers': { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers', brand: 'Under Armour' },
+ 'hiker-micro-crew-socks': { slug: 'hiker-micro-crew-socks', name: 'Hiker Micro Crew Socks', brand: 'Darn Tough' },
+ 'flat-cotton-laces': { slug: 'flat-cotton-laces', name: 'Flat Cotton Shoelaces', brand: 'Lace Lab' },
+ 'sneaker-cleaning-kit': { slug: 'sneaker-cleaning-kit', name: 'Sneaker Cleaning Kit', brand: 'Jason Markk' },
'990v6': { slug: '990v6', name: '990v6', brand: 'New Balance' },
'990v6': { slug: '990v6', name: '990v6', brand: 'New Balance' },
};
};
@@ -10,6 +10,7 @@ const configLib = require('../server/config');
@@ -10,6 +10,7 @@ const configLib = require('../server/config');
const { testDb } = require('./helpers/db');
const { testDb } = require('./helpers/db');
const { createStore } = require('../server/db');
const { createStore } = require('../server/db');
const { createCatalogue } = require('../server/catalogue');
const { createCatalogue } = require('../server/catalogue');
+const { createProductStore } = require('../server/product-store');
const { createUpstreams } = require('../server/upstreams');
const { createUpstreams } = require('../server/upstreams');
const { createOffersJob } = require('../server/jobs/offers');
const { createOffersJob } = require('../server/jobs/offers');
const { createDropsJob } = require('../server/jobs/drops');
const { createDropsJob } = require('../server/jobs/drops');
@@ -29,17 +30,20 @@ const { startUpstreams } = require('./helpers/upstreams');
@@ -29,17 +30,20 @@ const { startUpstreams } = require('./helpers/upstreams');
});
});
const s = createStore(test.db, { now: () => Date.parse('2026-10-31T22:00:00Z') });
const s = createStore(test.db, { now: () => Date.parse('2026-10-31T22:00:00Z') });
const catalogue = createCatalogue(s, { staleHours: 48 });
const catalogue = createCatalogue(s, { staleHours: 48 });
+ const productStore = createProductStore(s);
const upstreams = createUpstreams({ config, log: { warn() {}, log() {} } });
const upstreams = createUpstreams({ config, log: { warn() {}, log() {} } });
- const offersJob = createOffersJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
+ const offersJob = createOffersJob({ config, catalogue, products: productStore, upstreams, log: { log() {}, warn() {} } });
const dropsJob = createDropsJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
const dropsJob = createDropsJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
const ebayJob = createEbayJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
const ebayJob = createEbayJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
try {
try {
- await check('offers: only footwear, brand from the product, one model each', async () => {
+ await check('offers: shoes and categorized accessories, each with its public product source', async () => {
const out = await offersJob.runOnce({ pages: 2 });
const out = await offersJob.runOnce({ pages: 2 });
- equal(out.seen, 5);
- equal(out.candidates, 4, 'the joggers are not footwear');
+ equal(out.seen, 8);
+ equal(out.candidates, 7, 'the joggers are not a shoe or shoe accessory');
equal(out.offers, 4);
equal(out.offers, 4);
+ equal(out.products, 3);
+ equal(out.accessory_offers, 3);
const counts = await catalogue.counts();
const counts = await catalogue.counts();
equal(counts.models, 4);
equal(counts.models, 4);
equal(counts.offers, 4);
equal(counts.offers, 4);
@@ -56,11 +60,17 @@ const { startUpstreams } = require('./helpers/upstreams');
@@ -56,11 +60,17 @@ const { startUpstreams } = require('./helpers/upstreams');
equal(offers.length, 1);
equal(offers.length, 1);
equal(offers[0].all_in, 89.95);
equal(offers[0].all_in, 89.95);
equal(offers[0].drop_from_list, 40.05);
equal(offers[0].drop_from_list, 40.05);
+ const socks = await productStore.list({ kind: 'socks' });
+ equal(socks.length, 1);
+ equal(socks[0].source.name, 'OpenVibe.Deals');
+ equal((await productStore.offers(socks[0].id))[0].all_in, 25);
});
});
await check('offers: a second run does not duplicate an offer or snapshots', async () => {
await check('offers: a second run does not duplicate an offer or snapshots', async () => {
const out = await offersJob.runOnce({ pages: 2 });
const out = await offersJob.runOnce({ pages: 2 });
equal(out.offers, 4);
equal(out.offers, 4);
+ equal(out.accessory_offers, 3);
+ equal((await productStore.list({ kind: 'laces' })).length, 1);
const counts = await catalogue.counts();
const counts = await catalogue.counts();
equal(counts.offers, 4);
equal(counts.offers, 4);
equal(counts.snapshots, 4);
equal(counts.snapshots, 4);
@@ -98,6 +98,28 @@ const { assert, equal } = require('./helpers/check');
@@ -98,6 +98,28 @@ const { assert, equal } = require('./helpers/check');
assert(sent.headers.authorization.startsWith('Bearer '), 'the person token');
assert(sent.headers.authorization.startsWith('Bearer '), 'the person token');
});
});
+ await check('POST /kids/growth-check: opt-in creates a private reminder without a birthday', async () => {
+ const before = t.upstreams.state.todayRequests.length;
+ const noConsent = await t.get('/kids/growth-check', { as: user, method: 'POST', form: { nickname: 'M', size: '7', stage: 'toddler' } });
+ equal(noConsent.status, 302);
+ equal(t.upstreams.state.todayRequests.length, before);
+
+ const res = await t.get('/kids/growth-check', { as: user, method: 'POST', form: { consent: 'yes', nickname: 'M', size: '7', stage: 'toddler' } });
+ equal(res.status, 302);
+ equal(t.upstreams.state.todayRequests.length, before + 1);
+ const sent = t.upstreams.state.todayRequests[before].body;
+ equal(sent.kind, 'event');
+ equal(sent.visibility, 'private');
+ assert(sent.title.includes('M'), 'nickname can label the reminder');
+ assert(sent.description.includes('Current shoe size: 7'), 'only current size is included');
+ assert(!/birthday|birthdate|date of birth/i.test(sent.description.replace('This reminder contains no birthday.', '')), 'no birthday value is sent');
+ const due = new Date(sent.starts_at);
+ const now = new Date();
+ assert(due > now, 'the reminder is in the future');
+ const monthDistance = (due.getUTCFullYear() - now.getUTCFullYear()) * 12 + due.getUTCMonth() - now.getUTCMonth();
+ equal(monthDistance, 3);
+ });
+
await check('GET /track and POST /track: a pasted link becomes a watch', async () => {
await check('GET /track and POST /track: a pasted link becomes a watch', async () => {
const page = await t.get('/track', { as: user });
const page = await t.get('/track', { as: user });
equal(page.status, 200);
equal(page.status, 200);
@@ -130,6 +152,29 @@ const { assert, equal } = require('./helpers/check');
@@ -130,6 +152,29 @@ const { assert, equal } = require('./helpers/check');
assert(h.includes('OpenVibe.Watch'), 'the watch service');
assert(h.includes('OpenVibe.Watch'), 'the watch service');
});
});
+ await check('new product kinds, cited size tools and expansion guides are server rendered', async () => {
+ const products = await t.get('/api/v1/products?kind=shoe');
+ equal(products.status, 200);
+ assert(Array.isArray(products.json().products), 'products envelope');
+ const care = await t.get('/api/v1/products?kind=care');
+ equal(care.status, 200, care.text);
+ assert(care.json().products.some((p) => p.kind === 'care'), 'Deals care product is cataloged with provenance');
+ equal(care.json().source_note, null);
+ const lace = await t.get('/api/v1/tools/lace-length?eyelet_pairs=6');
+ equal(lace.status, 200);
+ equal(lace.json().cm, 105);
+ assert(lace.json().source.url.includes('fieggen.com'), 'formula is cited');
+ const sock = await t.get('/api/v1/sizes/convert?brand=darn-tough&system=women&size=8');
+ equal(sock.json().conversions[0].size, 'M');
+ const laces = await t.get('/laces');
+ assert(laces.text.includes('Bow-tie / Army') && laces.text.includes('Ian's Shoelace Site'), 'lacing guide and citation');
+ const ruler = await t.get('/kids/measure');
+ assert(ruler.text.includes('10 cm') && ruler.text.includes('Actual Size'), 'print ruler and calibration directions');
+ assert(ruler.text.includes('HowTo'), 'structured how-to data');
+ const kids = await t.get('/kids', { as: user });
+ assert(kids.text.includes('does not ask for a birthday'), 'no birth date collected');
+ });
+
await check('discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt', async () => {
await check('discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt', async () => {
const robots = await t.get('/robots.txt');
const robots = await t.get('/robots.txt');
equal(robots.status, 200);
equal(robots.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+
+const { check, done, equal, assert } = require('./helpers/check');
+const { laceLength, sizeConvert, asProduct, KINDS } = require('../server/product-catalog');
+
+(async () => {
+ await check('lacing estimates use the cited formula and disclose assumptions', () => {
+ const cross = laceLength({ eyeletPairs: 6 });
+ equal(cross.cm, 105);
+ equal(cross.inches, 40);
+ equal(cross.source.name, "Ian's Shoelace Site");
+ assert(cross.assumptions.horizontal_cm > 0);
+ equal(laceLength({ eyeletPairs: 6, method: 'bow-tie' }).cm, 90);
+ equal(laceLength({ eyeletPairs: 99 }), null);
+ });
+ await check('brand size conversion is sourced and rejects unknown charts', () => {
+ equal(sizeConvert({ system: 'women', size: '8' }).conversions[0].size, 'M');
+ equal(sizeConvert({ system: 'kids', size: '1' }).conversions[0].size, 'M');
+ equal(sizeConvert({ system: 'men', size: 15 }).conversions.length, 0);
+ equal(sizeConvert({ system: 'men', size: 10, brand: 'other' }), null);
+ });
+ await check('product kinds and shoe wire shape match the shared contract', () => {
+ equal(KINDS, ['shoe', 'laces', 'socks', 'care', 'insoles', 'kids']);
+ const p = asProduct({ slug: 'test-shoe', brand: 'Test', model: 'Runner', first_seen: '2026-11-01T00:00:00Z' });
+ equal(p.id, 'test-shoe');
+ equal(p.kind, 'shoe');
+ equal(p.facts, []);
+ });
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });