Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Add pricing schema, free API integrations (MTG bulk, TCGdex, Foolio), valuation and sort-by-price, card detail pricing UI, and nearby store discovery via home zip and OSM geo clients. Co-authored-by: Cursor <cursoragent@cursor.com>
320 lines
13 KiB
Markdown
320 lines
13 KiB
Markdown
---
|
||
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.**
|