--- name: pricing-online-local classification: feature success_metric: Users see honest online prices (MTG/Pokémon/Lorcana) with sync timestamps, can sort collections by price with correct foil-aware totals, and authenticated users with a saved zip see nearby game stores — all on $0 APIs. skip: [] status: implementers-complete created: 2026-09-08 branch: convoy/pricing-online-local design_direction: version: 1 locked_at: 2026-09-08 extension: incremental parent_system: liquid-glass product_type: consumer TCG collection + pricing utility pattern: authenticated app — card detail tabs + collection list + settings form style: Liquid Glass — warm ember accent on glass; honesty-first pricing metadata stack: nextjs-pages-router | react-18 | tailwind | css-vars generator: skipped generator_reason: Established Liquid Glass DS; incremental pricing surfaces only model_policy: default_session: auto roles: role-conductor: composer-2.5-fast role-architect: composer-2.5 role-ia-architect: composer-2.5-fast role-ux-reviewer: composer-2.5-fast role-ui-designer: composer-2.5-fast role-implementer: composer-2.5-fast role-reviewer: cursor-grok-4.5-high role-security-auditor: gpt-5.6-terra-medium --- # Convoy: Pricing — Online + Local Near Me (Free APIs Only) **Branch:** `convoy/pricing-online-local` **Constraint:** **$0 recurring** — no Google Places, no paid pricing tiers, no scraping. **Goal:** Wire honest online pricing + gentle local store discovery. --- ## Why Collection valuation, card detail, and purchase flows today show incomplete or wrong prices: Pokémon/Lorcana sync writes null prices, MTG totals use foil `market_price` for nonfoil rows, sort-by-price UI is inert, and Price History / Purchase tabs are stubs. Users need trustworthy online estimates and optional local store discovery without paid APIs. ## Scope **In:** Scryfall (MTG), TCGdex (Pokémon), Foilio (Lorcana) ingest; foil-aware valuation; sort-by-price; pricing + history APIs; Purchase / Price History tabs; home zip + nearby stores (Nominatim + Overpass). **Out:** Paid aggregators, scraping, in-store inventory/prices, buylist data, affiliate links. ## Roles invoked 1. Conductor ✓ 2. IA Architect ✓ 3. UI Designer ✓ (incremental — skipped ui-ux-pro-max) 4. UX Reviewer ✓ 5. Security Auditor ✓ (planning phase) 6. Architect ✓ → **Human gate 1** ← you are here 7. Implementers (13 briefs, `/multitask` where safe) 8. Audit fan-out (reviewer + security + design-system + a11y) ## Todos - [x] Planning roles (IA, UX, UI, Security, Architect) - [ ] Human gate 1 — plan approval - [ ] Phase 0 implementers (Briefs 1–7) - [ ] Phase 1 implementers (Briefs 8–9) - [ ] Phase 2 implementers (Briefs 10–13) - [ ] Audit fan-out + merge --- ## Free-only stack (recommended) ### Online pricing by game | Game | Source | Cost | Key? | Notes | |------|--------|------|------|-------| | **MTG** | [Scryfall](https://scryfall.com/docs/api) bulk + card API | Free | No | Already integrated. `prices.usd` / `usd_foil`. Polite rate limits (~10 req/s). | | **Pokemon** | [TCGdex](https://tcgdex.dev) REST | Free | **No** | Catalog + embedded `pricing.tcgplayer` (USD) + `pricing.cardmarket` (EUR). Hourly/daily updates. | | **Lorcana** | [Foilio feed](https://foilio.net/developers) `GET /api/prices/feed` | Free | **No** | JSON, CORS, cached 1h. EUR/USD as reported; match on `card_id` / name+set. | | **Fallback links** | TCGPlayer + eBay search URLs | Free | No | Already on card detail — keep for buy-outbound. | **Optional signup (still $0):** - [TCG Price Lookup](https://tcgpricelookup.com/tcg-api) — **10,000 req/month free** with API key (multi-game). Good for on-demand card detail if TCGdex/Foilio miss a card. **Do NOT use for this convoy:** - tcgapi.dev beyond 100/day (too tight for bulk sync) - Google Places (paid after credits) - Scraping TCGPlayer / Cardmarket pages ### Local “near me” (free) | Step | Source | Cost | |------|--------|------| | Zip → lat/lng | [Nominatim](https://nominatim.org/release-docs/develop/api/Search/) (OSM) | Free — **1 req/s max**, cache forever per zip | | Stores in radius | [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) `shop=games` | Free — cache 7 days per `(lat,lng,radius)` | Show: name, address, distance, `website` / `phone` tags, OpenStreetMap link. **No scraped in-store prices.** --- ## Decisions (planning round) | # | Question | Decision | | --- | --- | --- | | A | Nearby stores auth | **Auth required**; zip from saved `home_zip` only — no anonymous geocode relay (Security sev-3) | | B | Local stores placement | Subsection in **Purchase tab**, not a fourth tab | | C | Zip input | Manual zip in Preferences; geolocation **nice-to-have** later | | D | Online pricing visibility | Public (matches existing `/api/cards/[id]`) | | E | Price History empty state | Show tab with honest empty copy until snapshots accumulate | | F | Collection sort | **Server-side** `sort=price` on paginated APIs | | G | Settings placement | Home zip under **Preferences** panel | | H | Currency v1 | USD primary; respect `preferred_currency` where data exists | --- ## Design direction Incremental extension of Liquid Glass — no new token files. Prices use `.gradient-text-gold` / `var(--accent-flame)`; staleness uses neutral tertiary pills (not error red); glass on section containers only (never per-card grid). See full typography/spacing/badge spec in planning transcript; repo tokens win on conflict. **Surfaces:** Purchase tab price summary, Price History chart panel, staleness badge, collection sort control, settings zip field, nearby stores list. --- ## IA ### Affected routes **Pages:** `/card/[id]` [modified], `/settings` [modified], `/my-cards` [modified], `/collection/[identifier]` [modified], `/collections` [impacted], `/profile` [impacted], `/cards` [impacted] **API:** `GET /api/cards/[id]/pricing` [new], `GET /api/cards/[id]/price-history` [new], `GET /api/stores/nearby` [new], `GET|PUT /api/user/settings` [modified], `GET /api/cards/owned` [modified], bulk import jobs [impacted] ### User flow ```mermaid flowchart LR A[Settings: save zip] --> B[Card Detail] B --> C[Purchase tab] C --> D[Online NM price] C --> E[Buy links] C --> F[Stores near you] G[My Collection] --> H[Sort by Price] ``` ### Screen inventory | Screen | Path | New/modified | Notes | | --- | --- | --- | --- | | Card Detail — Purchase | `/card/[id]` | Modified | DB prices, staleness badge, outbound links, local stores subsection | | Card Detail — Price History | `/card/[id]` | Modified | Snapshot chart from sync-stored history | | Settings — zip | `/settings` | Modified | Home ZIP in Preferences | | My Collection | `/my-cards` | Modified | Sort by price + corrected estimated value | | List Detail | `/collection/[identifier]` | Modified | Wire existing sort dropdown | --- ## UX **Reuse:** `CardDetailView`, `useCardDetail`, `CollectionPageView`, `useCollectionView`, `StatCard`, `GlassSurface`, `Input`, `Button`, settings page patterns. **Key patterns:** DB-first pricing (no optimistic price UI); staleness badge with text date (not color-only); server-side sort; zip saves with explicit Settings Save button; local stores below purchase links. **A11y handoff:** Tab roles on card detail tabs; `aria-live` for pricing load; zip validation via `Input error`; 4.5:1 contrast on badge text; link purpose text on all outbound URLs. **Anti-patterns:** No “live” pricing copy; no per-card glass; no client-only sort across pages; no auto GPS on load. --- ## Security (planning) | Check | Status | Notes | | --- | --- | --- | | Auth boundary | ⚠️ | Pricing public OK; **nearby stores auth required** | | Authorization (IDOR) | ⚠️ | Zip settings owner-scoped; no arbitrary zip query | | Input / injection | ⚠️ | Overpass fixed-template only; validate zip + radius | | Secrets exposure | ✅ | Core path keyless; server-only optional key | | Dependencies | ⚠️ | Validate upstream JSON; native fetch preferred | **Top pre-merge fixes:** Overpass QL injection (sev 4), geocode relay without auth (sev 3), bulk sync admin gate (sev 3). Re-audit with file:line citations after implementer PR. --- ## Architecture ### Decomposition | Brief # | Title | Phase | Depends on | Est. PR | | --- | --- | --- | --- | --- | | 1 | Pricing schema migration | 0 | — | ~120 LOC | | 2 | Pricing foundation lib | 0 | 1 | ~280 LOC | | 3 | MTG bulk sync + snapshots | 0 | 2 | ~180 LOC | | 4 | TCGdex Pokémon pricing | 0 | 2 | ~320 LOC | | 5 | Foilio Lorcana pricing | 0 | 2 | ~280 LOC | | 6 | Foil-aware valuation APIs | 0 | 2 | ~150 LOC | | 7 | Sort by price wiring | 0 | 2, 6 | ~220 LOC | | 8 | Card pricing + history APIs | 1 | 2 | ~350 LOC | | 9 | Card detail pricing UI | 1 | 8 | ~380 LOC | | 10 | Geo clients + DB cache | 2 | 1 | ~300 LOC | | 11 | Stores nearby API | 2 | 10 | ~200 LOC | | 12 | Settings home zip | 2 | 1 | ~120 LOC | | 13 | Local stores card detail UI | 2 | 9, 11, 12 | ~200 LOC | **Brief files:** `.convoys/pricing-online-local/brief-*.md` ### Schema diff (migration `1783000000001_add-pricing-schema.js`) - `cards.prices_updated_at TIMESTAMPTZ` - `card_price_history` (daily snapshots, UNIQUE per card/day/source/type/currency) - `users.home_zip VARCHAR(10)` - `geo_zip_cache`, `geo_stores_cache` (DB-backed, 7d TTL on stores) ### slice_dependencies (multitask-ready) ```yaml slice_dependencies: - brief: 1 depends_on: [] files: [migrations/1783000000001_add-pricing-schema.js, docs/SCHEMA_MAP.md] - brief: 2 depends_on: [1] files: [lib/pricing/card-price.js, lib/pricing/price-history.js, lib/pricing/collection-valuation.js, lib/pricing/outbound-links.js] - brief: 3 depends_on: [2] files: [lib/card-import/bulk-sync.js] - brief: 4 depends_on: [2] files: [lib/pricing/tcgdex-client.js, lib/card-import/pokemon-bulk.js] - brief: 5 depends_on: [2] files: [lib/pricing/foilio-client.js, lib/card-import/lorcana-bulk.js] - brief: 6 depends_on: [2] files: [pages/api/user/stats.js, pages/api/collections/[identifier].js] - brief: 7 depends_on: [2, 6] files: [pages/api/cards/owned.js, pages/api/collections/[identifier]/cards.js, lib/use-collection-view.js, components/CollectionPageView.js, pages/my-cards.js] - brief: 8 depends_on: [2] files: [lib/pricing/pricing-service.js, pages/api/cards/[id]/pricing.js, pages/api/cards/[id]/price-history.js] - brief: 9 depends_on: [8] files: [components/card-detail/CardDetailPurchaseTab.js, components/card-detail/CardDetailPriceHistoryTab.js, components/CardDetailView.js, lib/use-card-detail.js] - brief: 10 depends_on: [1] files: [lib/pricing/nominatim.js, lib/pricing/overpass.js, lib/pricing/geo-cache.js] - brief: 11 depends_on: [10] files: [pages/api/stores/nearby.js, lib/rate-limit.js] - brief: 12 depends_on: [1] files: [pages/settings.js, pages/api/user/settings.js] - brief: 13 depends_on: [9, 11, 12] files: [components/card-detail/CardDetailLocalStores.js, components/CardDetailView.js, lib/use-card-detail.js] ``` **Parallelization:** After Brief 1 → Brief 2 → **parallel** Briefs 3, 4, 5, 6 → Brief 7 → Brief 8 → Brief 9 → **parallel** Briefs 10, 12 → Brief 11 → Brief 13. ### Risks | Risk | Mitigation | | --- | --- | | TCGdex ID mismatch | Match set_code + card_number; log unmatched | | Foilio card_id drift | Fallback name+set match | | Nominatim ToS | DB cache forever; 1 req/s; server-only | | Overpass load | 7d cache; auth + 1/hour/user | | Valuation on lists (no is_foil) | `COALESCE(current_price, market_price)` + tooltip | --- ## Phase checklist ### Phase 0 - [ ] Pokemon bulk → TCGdex prices - [ ] Lorcana bulk → Foilio feed - [ ] Fix MTG foil valuation bug - [ ] Sort by Price wired - [ ] Price snapshots on sync ### Phase 1 - [ ] `GET /api/cards/[id]/pricing` - [ ] Purchase tab + staleness badge - [ ] Price History tab ### Phase 2 - [ ] Settings home zip - [ ] `GET /api/stores/nearby` (auth-gated) - [ ] Local stores on card detail --- ## Env vars ```bash TCGDEX_BASE_URL=https://api.tcgdex.net/v2/en FOILIO_PRICES_FEED_URL=https://foilio.net/api/prices/feed?game=lorcana NOMINATIM_USER_AGENT=DeckHearth/1.0 (contact@stillwell.cloud) OVERPASS_URL=https://overpass-api.de/api/interpreter TCG_PRICE_LOOKUP_API_KEY= # optional PRICING_LOCAL_ENABLED=true ``` --- ## Gentleness rules | API | Rule | |-----|------| | Scryfall bulk | One download per sync; User-Agent set | | TCGdex | Batch by set; sleep 100ms between pages | | Foilio | One feed fetch/hour | | Nominatim | Max 1 req/s; cache zip→coords forever | | Overpass | Max 1 query/user/hour; 7d cache | --- **Architecture complete. 13 briefs created. Estimated PRs: 13. Awaiting human gate 1 (plan approval) before implementers run.**