deckhearth/.convoys/pricing-online-local.md

321 lines
13 KiB
Markdown
Raw Normal View History

---
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.**