From d0468015eb58637bab88ba3d350ae20b6af1ea59 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 8 Sep 2026 22:47:46 -0500 Subject: [PATCH] feat(pricing): convoy for online prices and local stores 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 --- .convoys/pricing-online-local.md | 320 ++++++++++++++++++ .../brief-1-pricing-schema-migration.md | 39 +++ .../brief-10-geo-clients-cache.md | 47 +++ .../brief-11-stores-nearby-api.md | 40 +++ .../brief-12-settings-home-zip.md | 39 +++ .../brief-13-local-stores-ui.md | 47 +++ .../brief-2-pricing-foundation-lib.md | 56 +++ .../brief-3-mtg-bulk-sync-snapshots.md | 44 +++ .../brief-4-tcgdex-pokemon-pricing.md | 44 +++ .../brief-5-foilio-lorcana-pricing.md | 43 +++ .../brief-6-foil-aware-valuation.md | 40 +++ .../brief-7-sort-by-price.md | 46 +++ .../brief-8-card-pricing-apis.md | 52 +++ .../brief-9-card-detail-pricing-ui.md | 53 +++ components/CardDetailView.js | 102 ++---- components/CollectionPageView.js | 21 +- .../card-detail/CardDetailLocalStores.js | 119 +++++++ .../card-detail/CardDetailPriceHistoryTab.js | 110 ++++++ .../card-detail/CardDetailPurchaseTab.js | 130 +++++++ docs/SCHEMA_MAP.md | 52 ++- lib/card-import/bulk-sync.js | 74 +++- lib/card-import/lorcana-bulk.js | 145 +++++++- lib/card-import/pokemon-bulk.js | 143 +++++++- lib/pricing/card-price.js | 31 ++ lib/pricing/collection-valuation.js | 19 ++ lib/pricing/foolio-client.js | 120 +++++++ lib/pricing/geo-cache.js | 110 ++++++ lib/pricing/nominatim.js | 69 ++++ lib/pricing/outbound-links.js | 32 ++ lib/pricing/overpass.js | 178 ++++++++++ lib/pricing/price-history.js | 78 +++++ lib/pricing/pricing-service.js | 256 ++++++++++++++ lib/pricing/tcgdex-client.js | 162 +++++++++ lib/rate-limit.js | 10 + lib/sql.js | 17 + lib/use-card-detail.js | 126 +++++++ lib/use-collection-view.js | 16 +- .../1783000000001_add-pricing-schema.js | 64 ++++ pages/api/cards/[id].js | 2 +- pages/api/cards/[id]/price-history.js | 32 ++ pages/api/cards/[id]/pricing.js | 24 ++ pages/api/cards/owned.js | 37 +- pages/api/collections/[identifier].js | 8 +- pages/api/collections/[identifier]/cards.js | 40 ++- pages/api/stores/nearby.js | 73 ++++ pages/api/user/settings.js | 23 +- pages/api/user/stats.js | 15 +- pages/my-cards.js | 28 +- pages/settings.js | 25 +- test/api/cards-owned-sort.test.js | 81 +++++ test/api/cards-price-history.test.js | 78 +++++ test/api/cards-pricing.test.js | 72 ++++ test/api/stores-nearby.test.js | 101 ++++++ test/api/user-settings-home-zip.test.js | 96 ++++++ test/api/user-stats-valuation.test.js | 152 +++++++++ test/components/CardDetailLocalStores.test.js | 42 +++ test/components/CardDetailPurchaseTab.test.js | 47 +++ test/lib/card-import/bulk-sync.test.js | 55 +++ test/lib/card-import/lorcana-bulk.test.js | 117 +++++++ test/lib/card-import/pokemon-bulk.test.js | 115 +++++++ test/lib/pricing/card-price.test.js | 26 ++ test/lib/pricing/collection-valuation.test.js | 17 + test/lib/pricing/foolio-client.test.js | 70 ++++ test/lib/pricing/geo-cache.test.js | 31 ++ test/lib/pricing/nominatim.test.js | 59 ++++ test/lib/pricing/outbound-links.test.js | 23 ++ test/lib/pricing/overpass.test.js | 64 ++++ test/lib/pricing/price-history.test.js | 31 ++ test/lib/pricing/tcgdex-client.test.js | 120 +++++++ 69 files changed, 4662 insertions(+), 136 deletions(-) create mode 100644 .convoys/pricing-online-local.md create mode 100644 .convoys/pricing-online-local/brief-1-pricing-schema-migration.md create mode 100644 .convoys/pricing-online-local/brief-10-geo-clients-cache.md create mode 100644 .convoys/pricing-online-local/brief-11-stores-nearby-api.md create mode 100644 .convoys/pricing-online-local/brief-12-settings-home-zip.md create mode 100644 .convoys/pricing-online-local/brief-13-local-stores-ui.md create mode 100644 .convoys/pricing-online-local/brief-2-pricing-foundation-lib.md create mode 100644 .convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md create mode 100644 .convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md create mode 100644 .convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md create mode 100644 .convoys/pricing-online-local/brief-6-foil-aware-valuation.md create mode 100644 .convoys/pricing-online-local/brief-7-sort-by-price.md create mode 100644 .convoys/pricing-online-local/brief-8-card-pricing-apis.md create mode 100644 .convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md create mode 100644 components/card-detail/CardDetailLocalStores.js create mode 100644 components/card-detail/CardDetailPriceHistoryTab.js create mode 100644 components/card-detail/CardDetailPurchaseTab.js create mode 100644 lib/pricing/card-price.js create mode 100644 lib/pricing/collection-valuation.js create mode 100644 lib/pricing/foolio-client.js create mode 100644 lib/pricing/geo-cache.js create mode 100644 lib/pricing/nominatim.js create mode 100644 lib/pricing/outbound-links.js create mode 100644 lib/pricing/overpass.js create mode 100644 lib/pricing/price-history.js create mode 100644 lib/pricing/pricing-service.js create mode 100644 lib/pricing/tcgdex-client.js create mode 100644 migrations/1783000000001_add-pricing-schema.js create mode 100644 pages/api/cards/[id]/price-history.js create mode 100644 pages/api/cards/[id]/pricing.js create mode 100644 pages/api/stores/nearby.js create mode 100644 test/api/cards-owned-sort.test.js create mode 100644 test/api/cards-price-history.test.js create mode 100644 test/api/cards-pricing.test.js create mode 100644 test/api/stores-nearby.test.js create mode 100644 test/api/user-settings-home-zip.test.js create mode 100644 test/api/user-stats-valuation.test.js create mode 100644 test/components/CardDetailLocalStores.test.js create mode 100644 test/components/CardDetailPurchaseTab.test.js create mode 100644 test/lib/card-import/bulk-sync.test.js create mode 100644 test/lib/card-import/lorcana-bulk.test.js create mode 100644 test/lib/card-import/pokemon-bulk.test.js create mode 100644 test/lib/pricing/card-price.test.js create mode 100644 test/lib/pricing/collection-valuation.test.js create mode 100644 test/lib/pricing/foolio-client.test.js create mode 100644 test/lib/pricing/geo-cache.test.js create mode 100644 test/lib/pricing/nominatim.test.js create mode 100644 test/lib/pricing/outbound-links.test.js create mode 100644 test/lib/pricing/overpass.test.js create mode 100644 test/lib/pricing/price-history.test.js create mode 100644 test/lib/pricing/tcgdex-client.test.js diff --git a/.convoys/pricing-online-local.md b/.convoys/pricing-online-local.md new file mode 100644 index 0000000..ab07709 --- /dev/null +++ b/.convoys/pricing-online-local.md @@ -0,0 +1,320 @@ +--- +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.** diff --git a/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md b/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md new file mode 100644 index 0000000..2cc8db8 --- /dev/null +++ b/.convoys/pricing-online-local/brief-1-pricing-schema-migration.md @@ -0,0 +1,39 @@ +--- +convoy: pricing-online-local +brief_number: 1 +depends_on: [] +recommended_model: composer-2.5 +model_tier: standard +files: + - migrations/1783000000001_add-pricing-schema.js + - docs/SCHEMA_MAP.md +--- + +# Brief 1: Pricing schema migration + +## Goal (1 sentence) + +Ship the node-pg-migrate migration for pricing timestamps, price history, user home zip, and geo caches. + +## Files in scope (do not edit anything else) +- `migrations/1783000000001_add-pricing-schema.js` +- `docs/SCHEMA_MAP.md` + +## Conventions to follow +- Generate via `npm run migrate create add-pricing-schema -- -j js`; apply with `npm run migrate up`. +- `down()` must hard-stub (throw) — see `migrations/1779853647564_initial-schema.js`. +- Update `docs/SCHEMA_MAP.md` for every new column/table (AGENTS.md § Schema changes). +- Use `ADD COLUMN IF NOT EXISTS` / `CREATE TABLE IF NOT EXISTS` for idempotency. + +## Acceptance criteria +- [ ] Migration adds `cards.prices_updated_at TIMESTAMPTZ` +- [ ] Migration creates `card_price_history` with UNIQUE `(card_id, source, price_type, currency, recorded_at)` and index on `(card_id, recorded_at DESC)` +- [ ] Migration adds `users.home_zip VARCHAR(10)` +- [ ] Migration creates `geo_zip_cache` and `geo_stores_cache` tables per Architecture §3 +- [ ] `docs/SCHEMA_MAP.md` updated +- [ ] `npm run migrate up` succeeds on fresh + existing DB +- [ ] No scope expansion (do not edit files outside `files:` above) + +## Rationale (≤3 sentences) + +All pricing and geo features depend on these columns/tables. Landing schema first unblocks parallel implementers for bulk ingest, APIs, and settings without merge conflicts on migration files. diff --git a/.convoys/pricing-online-local/brief-10-geo-clients-cache.md b/.convoys/pricing-online-local/brief-10-geo-clients-cache.md new file mode 100644 index 0000000..db9b3c1 --- /dev/null +++ b/.convoys/pricing-online-local/brief-10-geo-clients-cache.md @@ -0,0 +1,47 @@ +--- +convoy: pricing-online-local +brief_number: 10 +depends_on: [1] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/pricing/nominatim.js + - lib/pricing/overpass.js + - lib/pricing/geo-cache.js + - test/lib/pricing/nominatim.test.js + - test/lib/pricing/overpass.test.js + - test/lib/pricing/geo-cache.test.js +--- + +# Brief 10: Geo clients + DB cache + +## Goal (1 sentence) + +Implement Nominatim geocoding, Overpass store queries, and DB-backed caches per convoy gentleness rules. + +## Files in scope (do not edit anything else) +- `lib/pricing/nominatim.js` +- `lib/pricing/overpass.js` +- `lib/pricing/geo-cache.js` +- `test/lib/pricing/nominatim.test.js` +- `test/lib/pricing/overpass.test.js` +- `test/lib/pricing/geo-cache.test.js` + +## Conventions to follow +- **Security:** Overpass QL uses fixed template only; inject parsed floats for lat/lng/radiusMeters — never user strings. +- `nominatim.js`: User-Agent from `NOMINATIM_USER_AGENT`; min 1s between requests. +- `overpass.js`: query `shop=games` and `shop=hobby`; radius capped 1–50 miles server-side. +- `geo-cache.js`: zip coords cached forever; stores cache 7-day TTL. +- Allowlist outbound hosts at module init. + +## Acceptance criteria +- [ ] Zip cache hit skips Nominatim +- [ ] Stores cache respects 7-day TTL +- [ ] Overpass parser returns distance-ready lat/lng per store +- [ ] Unit tests cover parser + TTL logic +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Isolates external geo APIs from the route handler for testability. DB cache avoids Redis dependency for long-lived zip coords. Satisfies convoy rate-limit requirements before Brief 11 wires the API. diff --git a/.convoys/pricing-online-local/brief-11-stores-nearby-api.md b/.convoys/pricing-online-local/brief-11-stores-nearby-api.md new file mode 100644 index 0000000..5fbb278 --- /dev/null +++ b/.convoys/pricing-online-local/brief-11-stores-nearby-api.md @@ -0,0 +1,40 @@ +--- +convoy: pricing-online-local +brief_number: 11 +depends_on: [10] +recommended_model: composer-2.5 +model_tier: standard +files: + - pages/api/stores/nearby.js + - lib/rate-limit.js + - test/api/stores-nearby.test.js +--- + +# Brief 11: Stores nearby API + +## Goal (1 sentence) + +Ship `GET /api/stores/nearby` with geocode + Overpass lookup, caching, and per-user refresh rate limit. + +## Files in scope (do not edit anything else) +- `pages/api/stores/nearby.js` +- `lib/rate-limit.js` +- `test/api/stores-nearby.test.js` + +## Conventions to follow +- **Auth required** (`getUserFromRequest` → 401). Zip from user's saved `home_zip` only — ignore arbitrary `zip` query param (security planning decision A). +- Add `stores` limiter: 1/hour/user; IP backstop for burst abuse. +- Respect `PRICING_LOCAL_ENABLED` — return 503 when `false`. +- Overpass fixed-template query only (see Brief 10). + +## Acceptance criteria +- [ ] Endpoint returns store list for authenticated user with saved zip +- [ ] 401 without Bearer token +- [ ] Second call within 7 days serves from `geo_stores_cache` +- [ ] Rate limit returns 429 with `Retry-After` +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Phase 2 API surface from convoy spec. Auth-gated zip prevents geocode relay abuse. Rate limiting protects Overpass. diff --git a/.convoys/pricing-online-local/brief-12-settings-home-zip.md b/.convoys/pricing-online-local/brief-12-settings-home-zip.md new file mode 100644 index 0000000..1d8903a --- /dev/null +++ b/.convoys/pricing-online-local/brief-12-settings-home-zip.md @@ -0,0 +1,39 @@ +--- +convoy: pricing-online-local +brief_number: 12 +depends_on: [1] +recommended_model: composer-2.5 +model_tier: standard +files: + - pages/settings.js + - pages/api/user/settings.js + - test/api/user-settings-home-zip.test.js +--- + +# Brief 12: Settings home zip + +## Goal (1 sentence) + +Let users save a home zip code in Settings for local store discovery. + +## Files in scope (do not edit anything else) +- `pages/settings.js` +- `pages/api/user/settings.js` +- `test/api/user-settings-home-zip.test.js` + +## Conventions to follow +- Auth: `getUserFromRequest` → 401 if null. +- Validate zip: 5-digit US; store 5-digit string; allow `null` to clear. +- Settings UI: field under **Preferences** — “Home ZIP code (for nearby game stores)”. +- Owner-scoped read/write only (`user_id = session.userId`). + +## Acceptance criteria +- [ ] GET settings includes `home_zip` +- [ ] PUT persists valid zip; 400 on invalid +- [ ] Settings page loads and saves zip +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Phase 2 requires a default location without GPS. Reuses existing settings flow. Independent of geo API briefs once migration lands. diff --git a/.convoys/pricing-online-local/brief-13-local-stores-ui.md b/.convoys/pricing-online-local/brief-13-local-stores-ui.md new file mode 100644 index 0000000..c368e57 --- /dev/null +++ b/.convoys/pricing-online-local/brief-13-local-stores-ui.md @@ -0,0 +1,47 @@ +--- +convoy: pricing-online-local +brief_number: 13 +depends_on: [9, 11, 12] +recommended_model: composer-2.5 +model_tier: standard +files: + - components/card-detail/CardDetailLocalStores.js + - components/CardDetailView.js + - lib/use-card-detail.js + - test/components/CardDetailLocalStores.test.js +cross_brief_commitments: + - brief: 9 + description: | + Inserts local stores section into the purchase tab area using the + slot Brief 9 reserved. Does not rework purchase pricing UI. +--- + +# Brief 13: Local stores card detail UI + +## Goal (1 sentence) + +Show “Game stores near you” on card detail using saved home zip and the nearby stores API. + +## Files in scope (do not edit anything else) +- `components/card-detail/CardDetailLocalStores.js` +- `components/CardDetailView.js` +- `lib/use-card-detail.js` +- `test/components/CardDetailLocalStores.test.js` + +## Conventions to follow +- Fetch `/api/stores/nearby` when purchase tab active (auth required). +- List: name, address, distance, phone/website links, OSM link — no in-store prices. +- Empty states: no zip → link to Settings; no stores → gentle copy. +- Liquid Glass styling consistent with Brief 9 components. + +## Acceptance criteria +- [ ] Stores render when user has home zip configured +- [ ] Prompt to set zip when missing +- [ ] No scraped in-store pricing shown +- [ ] Component test covers empty + populated states +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Completes Phase 2 user-facing goal on card detail. Depends on pricing UI shell from Brief 9 and backend from Briefs 11–12. diff --git a/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md b/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md new file mode 100644 index 0000000..17cad78 --- /dev/null +++ b/.convoys/pricing-online-local/brief-2-pricing-foundation-lib.md @@ -0,0 +1,56 @@ +--- +convoy: pricing-online-local +brief_number: 2 +depends_on: [1] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/pricing/card-price.js + - lib/pricing/price-history.js + - lib/pricing/collection-valuation.js + - lib/pricing/outbound-links.js + - test/lib/pricing/card-price.test.js + - test/lib/pricing/price-history.test.js + - test/lib/pricing/collection-valuation.test.js +cross_brief_commitments: + - brief: 3 + description: | + Briefs 3–5 import `recordPriceSnapshots` from `lib/pricing/price-history.js` + and set `prices_updated_at` after successful price writes. They must call + the shared helpers rather than duplicating snapshot SQL. +--- + +# Brief 2: Pricing foundation lib + +## Goal (1 sentence) + +Create shared pricing helpers for foil-aware unit prices, daily history snapshots, collection valuation SQL, and outbound buy links. + +## Files in scope (do not edit anything else) +- `lib/pricing/card-price.js` +- `lib/pricing/price-history.js` +- `lib/pricing/collection-valuation.js` +- `lib/pricing/outbound-links.js` +- `test/lib/pricing/card-price.test.js` +- `test/lib/pricing/price-history.test.js` +- `test/lib/pricing/collection-valuation.test.js` + +## Conventions to follow +- ESM (`import`/`export`); DB via `import { sql } from '../sql.js'` (relative from `lib/pricing/`). +- `card-price.js`: export `resolveUnitPrice({ currentPrice, marketPrice, isFoil })` — nonfoil prefers `current_price`, foil prefers `market_price`; export `PRICE_STALE_HOURS = 24`. +- `collection-valuation.js`: export `effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' })` returning SQL `CASE WHEN uc.is_foil THEN c.market_price ELSE COALESCE(c.current_price, c.market_price) END`; export `listUnitPriceExpr({ cardAlias = 'c' })` → `COALESCE(c.current_price, c.market_price)`. +- `price-history.js`: export `recordPriceSnapshots(rows)` where each row is `{ cardId, source, priceType, currency, amount }`; upsert on daily UNIQUE key. +- `outbound-links.js`: extract TCGPlayer + eBay URL logic currently inline in `CardDetailView.js` (game-aware). +- Mirror test style from `test/lib/collection-vocabulary.test.js`. + +## Acceptance criteria +- [ ] `resolveUnitPrice` returns foil vs nonfoil correctly for MTG-shaped data +- [ ] `recordPriceSnapshots` is idempotent for same card/day/source/type +- [ ] `effectiveUnitPriceExpr` produces valid SQL fragment (unit-tested via known inputs or integration mock) +- [ ] `buildTcgplayerUrl(card)` and `buildEbayUrl(card)` exported +- [ ] All three test files pass (`npm run test:run`) +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Centralizes pricing semantics so bulk sync, valuation fix, and APIs cannot drift. The foil bug (`market_price` = Scryfall `usd_foil`) is fixed once at the helper layer. Snapshot writer gives Briefs 3–5 a single hook. diff --git a/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md b/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md new file mode 100644 index 0000000..d14c072 --- /dev/null +++ b/.convoys/pricing-online-local/brief-3-mtg-bulk-sync-snapshots.md @@ -0,0 +1,44 @@ +--- +convoy: pricing-online-local +brief_number: 3 +depends_on: [2] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/card-import/bulk-sync.js + - test/lib/card-import/bulk-sync.test.js +cross_brief_commitments: + - brief: 2 + description: | + Uses `recordPriceSnapshots` and sets `prices_updated_at = CURRENT_TIMESTAMP` + on each upsert batch where price columns change. +--- + +# Brief 3: MTG bulk sync + snapshots + +## Goal (1 sentence) + +Update Scryfall bulk sync to stamp `prices_updated_at` and record daily price history snapshots. + +## Files in scope (do not edit anything else) +- `lib/card-import/bulk-sync.js` +- `test/lib/card-import/bulk-sync.test.js` + +## Conventions to follow +- Keep existing `mapCard()` mapping: `usd` → `current_price`, `usd_foil` → `market_price`. +- Add `prices_updated_at` to INSERT/UPDATE columns (set when either price is non-null). +- After each successful `upsertBatch`, call `recordPriceSnapshots` for rows with prices (`source: 'scryfall'`, `priceType: 'market'` for nonfoil amount, optionally second snapshot for foil if present). +- Export `mapCard` for unit tests (or test via named export already present). +- Preserve Scryfall User-Agent header. + +## Acceptance criteria +- [ ] `upsertBatch` writes `prices_updated_at` +- [ ] Price snapshots recorded per card per sync day +- [ ] `mapCard` unit tests cover price parsing +- [ ] Existing `runBulkMtgSync` return shape unchanged (add optional `snapshotsWritten` count) +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +MTG already has price data from Scryfall; this brief wires timestamps and history without changing upstream source. Fixes the data pipeline for Phase 1 pricing API reads. diff --git a/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md b/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md new file mode 100644 index 0000000..ec0bad8 --- /dev/null +++ b/.convoys/pricing-online-local/brief-4-tcgdex-pokemon-pricing.md @@ -0,0 +1,44 @@ +--- +convoy: pricing-online-local +brief_number: 4 +depends_on: [2] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/pricing/tcgdex-client.js + - lib/card-import/pokemon-bulk.js + - test/lib/pricing/tcgdex-client.test.js + - test/lib/card-import/pokemon-bulk.test.js +--- + +# Brief 4: TCGdex Pokémon pricing ingest + +## Goal (1 sentence) + +Ingest USD TCGPlayer prices from TCGdex during Pokémon bulk sync instead of writing null prices. + +## Files in scope (do not edit anything else) +- `lib/pricing/tcgdex-client.js` +- `lib/card-import/pokemon-bulk.js` +- `test/lib/pricing/tcgdex-client.test.js` +- `test/lib/card-import/pokemon-bulk.test.js` + +## Conventions to follow +- Base URL from `process.env.TCGDEX_BASE_URL ?? 'https://api.tcgdex.net/v2/en'`. +- Client: `fetchSetCards(setId)` with 100ms delay between set requests; parse `pricing.tcgplayer` (USD market/low) and `pricing.cardmarket` (EUR — store in `market_price` only when USD missing, document in code comment). +- Match TCGdex cards to DB rows by `set_code` + `card_number` (normalize leading zeros); fallback name match within set. +- `mapPokemonBulkRow` / `buildUpsertQuery`: populate `current_price` (low or market NM), `market_price` (market), `prices_updated_at`. +- Call `recordPriceSnapshots` with `source: 'tcgdex'`. +- Log `unmatchedCount` in sync summary JSON. + +## Acceptance criteria +- [ ] Pokémon bulk no longer writes `null` prices when TCGdex has data +- [ ] Gentleness: 100ms sleep between set fetches +- [ ] Unit tests with mocked fetch fixtures +- [ ] `runBulkPokemonSync` summary includes `pricesMatched` / `unmatchedCount` +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Pokémon catalog sync currently nulls prices. TCGdex is the convoy's free USD source. Set-batched fetch keeps within polite limits. diff --git a/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md b/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md new file mode 100644 index 0000000..8683d61 --- /dev/null +++ b/.convoys/pricing-online-local/brief-5-foilio-lorcana-pricing.md @@ -0,0 +1,43 @@ +--- +convoy: pricing-online-local +brief_number: 5 +depends_on: [2] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/pricing/foilio-client.js + - lib/card-import/lorcana-bulk.js + - test/lib/pricing/foilio-client.test.js + - test/lib/card-import/lorcana-bulk.test.js +--- + +# Brief 5: Foilio Lorcana pricing ingest + +## Goal (1 sentence) + +Enrich Lorcana bulk sync with hourly Foilio feed prices keyed by card id and name+set. + +## Files in scope (do not edit anything else) +- `lib/pricing/foilio-client.js` +- `lib/card-import/lorcana-bulk.js` +- `test/lib/pricing/foilio-client.test.js` +- `test/lib/card-import/lorcana-bulk.test.js` + +## Conventions to follow +- Feed URL: `process.env.FOILIO_PRICES_FEED_URL ?? 'https://foilio.net/api/prices/feed?game=lorcana'`. +- Client fetches once per sync run; build `Map` by `card_id` and composite `normalize(name)|set`. +- Extend `buildUpsertQuery` / `mapLorcanaCard` to include `current_price`, `market_price`, `prices_updated_at`. +- Prefer USD fields when present; otherwise EUR with `currency` noted in snapshot. +- `recordPriceSnapshots` with `source: 'foilio'`. +- Preserve existing `scryfall_id = Unique_ID` keying. + +## Acceptance criteria +- [ ] Lorcana cards receive prices when feed contains a match +- [ ] Single feed fetch per `runBulkLorcanaSync` invocation +- [ ] Summary includes `pricesMatched` / `unmatchedCount` +- [ ] Tests pass with fixture JSON +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Lorcana bulk currently omits price columns entirely. Foilio provides a free hourly JSON feed. One fetch per sync aligns with their 1h cache. diff --git a/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md b/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md new file mode 100644 index 0000000..ab10777 --- /dev/null +++ b/.convoys/pricing-online-local/brief-6-foil-aware-valuation.md @@ -0,0 +1,40 @@ +--- +convoy: pricing-online-local +brief_number: 6 +depends_on: [2] +recommended_model: composer-2.5 +model_tier: standard +files: + - pages/api/user/stats.js + - pages/api/collections/[identifier].js + - test/api/user-stats-valuation.test.js +--- + +# Brief 6: Foil-aware valuation APIs + +## Goal (1 sentence) + +Fix collection total value to use nonfoil `current_price` for `user_cards.is_foil = false` rows. + +## Files in scope (do not edit anything else) +- `pages/api/user/stats.js` +- `pages/api/collections/[identifier].js` +- `test/api/user-stats-valuation.test.js` + +## Conventions to follow +- Import `effectiveUnitPriceExpr` and `listUnitPriceExpr` from `lib/pricing/collection-valuation.js`. +- `stats.js`: replace `cards.market_price * quantity` with effective unit price expression in value, game breakdown, rarity breakdown, and recent activity. +- `collections/[identifier].js`: replace `SUM(cards.market_price * cc.quantity)` with `listUnitPriceExpr`. +- Auth pattern unchanged (`getUserFromRequest`). + +## Acceptance criteria +- [ ] Nonfoil `user_cards` row valued at `current_price`, not foil `market_price` +- [ ] Foil row still uses `market_price` +- [ ] Collection list totals use `COALESCE(current_price, market_price)` +- [ ] Regression test locks MTG foil/nonfoil scenario +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +This is the user-visible valuation bug from Phase 0. `market_price` stores Scryfall `usd_foil` for MTG. Fixing at the API layer immediately improves dashboard and profile totals. diff --git a/.convoys/pricing-online-local/brief-7-sort-by-price.md b/.convoys/pricing-online-local/brief-7-sort-by-price.md new file mode 100644 index 0000000..e546c66 --- /dev/null +++ b/.convoys/pricing-online-local/brief-7-sort-by-price.md @@ -0,0 +1,46 @@ +--- +convoy: pricing-online-local +brief_number: 7 +depends_on: [2, 6] +recommended_model: composer-2.5 +model_tier: standard +files: + - pages/api/cards/owned.js + - pages/api/collections/[identifier]/cards.js + - lib/use-collection-view.js + - components/CollectionPageView.js + - pages/my-cards.js + - test/api/cards-owned-sort.test.js +--- + +# Brief 7: Sort by price wiring + +## Goal (1 sentence) + +Wire “Sort by Price” through owned-cards and list APIs into collection and My Collection UIs. + +## Files in scope (do not edit anything else) +- `pages/api/cards/owned.js` +- `pages/api/collections/[identifier]/cards.js` +- `lib/use-collection-view.js` +- `components/CollectionPageView.js` +- `pages/my-cards.js` +- `test/api/cards-owned-sort.test.js` + +## Conventions to follow +- Query params: `sort=name|price|rarity|date`, `order=asc|desc` (default `name`/`asc`). +- Price sort uses `listUnitPriceExpr` or `COALESCE(c.current_price, c.market_price)`; NULLs last. +- `CollectionPageView` select options must use stable values (`name`, `price`, etc.) not display strings. +- Server-side sort required for paginated My Collection. + +## Acceptance criteria +- [ ] `/api/cards/owned?sort=price&order=desc` orders by effective price +- [ ] Collection cards API supports same params +- [ ] UI sort dropdown changes card order +- [ ] API test asserts ORDER BY clause or result ordering +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Sort UI exists but is inert. Server-side sort avoids loading entire collections client-side. Depends on valuation helpers for consistent price column. diff --git a/.convoys/pricing-online-local/brief-8-card-pricing-apis.md b/.convoys/pricing-online-local/brief-8-card-pricing-apis.md new file mode 100644 index 0000000..952a82c --- /dev/null +++ b/.convoys/pricing-online-local/brief-8-card-pricing-apis.md @@ -0,0 +1,52 @@ +--- +convoy: pricing-online-local +brief_number: 8 +depends_on: [2] +recommended_model: composer-2.5 +model_tier: standard +files: + - lib/pricing/pricing-service.js + - pages/api/cards/[id]/pricing.js + - pages/api/cards/[id]/price-history.js + - pages/api/cards/[id].js + - test/api/cards-pricing.test.js + - test/api/cards-price-history.test.js +cross_brief_commitments: + - brief: 9 + description: | + Response shapes from `/api/cards/[id]/pricing` and + `/api/cards/[id]/price-history` are the contract Brief 9's UI consumes. + Do not rename fields without updating Brief 9. +--- + +# Brief 8: Card pricing + history APIs + +## Goal (1 sentence) + +Expose `GET /api/cards/[id]/pricing` and `GET /api/cards/[id]/price-history` reading DB with optional stale refresh. + +## Files in scope (do not edit anything else) +- `lib/pricing/pricing-service.js` +- `pages/api/cards/[id]/pricing.js` +- `pages/api/cards/[id]/price-history.js` +- `pages/api/cards/[id].js` +- `test/api/cards-pricing.test.js` +- `test/api/cards-price-history.test.js` + +## Conventions to follow +- API route pattern from `.cursor/rules/api-routes.mdc`: method gate, try/catch, manual validation. +- `pricing-service.js`: DB-first; live refresh only when stale >24h with Redis single-flight lock. +- Include `outbound` links via `outbound-links.js`. +- `price-history.js` API: query `card_price_history` last N days (default 90, max 365). + +## Acceptance criteria +- [ ] `GET /api/cards/123/pricing` returns prices + `pricesUpdatedAt` + `stale` boolean +- [ ] `GET /api/cards/123/price-history?days=30` returns `{ points: [...] }` +- [ ] 404 when card missing; 400 on invalid id/days +- [ ] Live refresh does not throw on upstream failure +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Phase 1 backend contract for Purchase and Price History tabs. DB-first path respects free API limits; 24h staleness matches convoy spec. diff --git a/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md b/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md new file mode 100644 index 0000000..004cb6a --- /dev/null +++ b/.convoys/pricing-online-local/brief-9-card-detail-pricing-ui.md @@ -0,0 +1,53 @@ +--- +convoy: pricing-online-local +brief_number: 9 +depends_on: [8] +recommended_model: composer-2.5 +model_tier: standard +files: + - components/card-detail/CardDetailPurchaseTab.js + - components/card-detail/CardDetailPriceHistoryTab.js + - components/CardDetailView.js + - lib/use-card-detail.js + - test/components/CardDetailPurchaseTab.test.js +cross_brief_commitments: + - brief: 8 + description: | + Consumes `/api/cards/[id]/pricing` and `/api/cards/[id]/price-history` + response shapes defined in Brief 8. + - brief: 13 + description: | + Brief 13 will add `CardDetailLocalStores` below the purchase panel. + Leave a slot in purchase tab wrapper for Brief 13 insertion. +--- + +# Brief 9: Card detail pricing UI + +## Goal (1 sentence) + +Replace placeholder Purchase and Price History tabs with live pricing data, staleness badge, and a simple history chart. + +## Files in scope (do not edit anything else) +- `components/card-detail/CardDetailPurchaseTab.js` +- `components/card-detail/CardDetailPriceHistoryTab.js` +- `components/CardDetailView.js` +- `lib/use-card-detail.js` +- `test/components/CardDetailPurchaseTab.test.js` + +## Conventions to follow +- Liquid Glass surfaces: `glass-panel`, CSS variables — no hardcoded hex. +- Purchase tab: staleness badge, outbound links from API `outbound`. +- Price History tab: line chart from `points`; empty state when none. +- Reserve insertion point for Brief 13 local stores section. + +## Acceptance criteria +- [ ] Purchase tab shows DB prices and staleness badge +- [ ] Price History tab renders data when points exist; empty state when none +- [ ] No placeholder “available soon” copy remains +- [ ] Component test verifies staleness badge text +- [ ] Tests pass +- [ ] No scope expansion + +## Rationale (≤3 sentences) + +Delivers Phase 1 user-facing value on existing tab chrome. Keeps `CardDetailView` thin by extracting tab bodies. Documents insertion point for Phase 2 local stores. diff --git a/components/CardDetailView.js b/components/CardDetailView.js index bb5fe66..27a9ecd 100644 --- a/components/CardDetailView.js +++ b/components/CardDetailView.js @@ -5,6 +5,9 @@ import CardDetailDeckModal from './CardDetailDeckModal'; import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols'; import ManaSymbolSettings from './ManaSymbolSettings'; import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js'; +import CardDetailPurchaseTab from './card-detail/CardDetailPurchaseTab'; +import CardDetailPriceHistoryTab from './card-detail/CardDetailPriceHistoryTab'; + export default function CardDetailView(props) { const { activeTab, @@ -30,8 +33,16 @@ export default function CardDetailView(props) { isFavorited, loading, manaSymbolSettings, + nearbyStores, + nearbyStoresError, + nearbyStoresLoading, + nearbyStoresNoHomeZip, ownedQuantity, particleStyles, + priceHistory, + priceHistoryLoading, + pricing, + pricingLoading, quantity, router, selectedCollection, @@ -344,85 +355,24 @@ export default function CardDetailView(props) { )} {activeTab === 'price-history' && ( -
-

- Price History -

- - {/* Price Graph Placeholder */} -
-

- Price Trend -

-
-

- Price history data will be available soon -

-
-
- - {/* Price Statistics Cards */} -
- {/* Current Price */} -
-
-
💰
-
- Current Price -
-
- {formatCurrency(card.current_price || 0)} -
-
-
-
-
+ )} {activeTab === 'purchase' && ( -
-

- Where to Buy -

-
- -
- 🃏 -
-

- TCGPlayer -

-

- View on TCGPlayer -

-
-
-
- -
- 🛒 -
-

- eBay -

-

- View on eBay -

-
-
-
-
-
+ )} diff --git a/components/CollectionPageView.js b/components/CollectionPageView.js index 98ae6e7..7517a7f 100644 --- a/components/CollectionPageView.js +++ b/components/CollectionPageView.js @@ -63,6 +63,7 @@ export default function CollectionPageView(props) { setShowShareModal, setShowUploadModal, setSortBy, + setSortOrder, setViewMode, showDeleteModal, showEditModal, @@ -71,6 +72,7 @@ export default function CollectionPageView(props) { showShareModal, showUploadModal, sortBy, + sortOrder, toggleFavorite, togglePublic, user, @@ -344,11 +346,22 @@ export default function CollectionPageView(props) { value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="input-field" + aria-label="Sort cards" > - - - - + + + + + + + diff --git a/components/card-detail/CardDetailLocalStores.js b/components/card-detail/CardDetailLocalStores.js new file mode 100644 index 0000000..1a3c07a --- /dev/null +++ b/components/card-detail/CardDetailLocalStores.js @@ -0,0 +1,119 @@ +import Link from 'next/link'; + +/** + * Nearby game stores subsection for the Purchase tab. + */ +export default function CardDetailLocalStores({ + stores = [], + loading = false, + error = null, + noHomeZip = false, +}) { + if (loading) { + return ( +
+

Loading nearby stores…

+
+ ); + } + + if (noHomeZip) { + return ( +
+

+ Game stores near you +

+

+ Add your home ZIP in Settings to see nearby game and hobby stores. +

+ + Set home ZIP in Settings + +
+ ); + } + + if (error) { + return ( +
+

+ Game stores near you +

+

+ {error} +

+
+ ); + } + + return ( +
+

+ Game stores near you +

+ + {stores.length === 0 ? ( +

+ No game or hobby stores found in your area yet. Try again later — store data comes from OpenStreetMap. +

+ ) : ( + + )} +
+ ); +} diff --git a/components/card-detail/CardDetailPriceHistoryTab.js b/components/card-detail/CardDetailPriceHistoryTab.js new file mode 100644 index 0000000..368efdc --- /dev/null +++ b/components/card-detail/CardDetailPriceHistoryTab.js @@ -0,0 +1,110 @@ +function buildChartGeometry(points, width, height, padding) { + if (!points.length) return null; + + const amounts = points.map((point) => point.amount); + const min = Math.min(...amounts); + const max = Math.max(...amounts); + const range = max - min || 1; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + + const coordinates = points.map((point, index) => { + const x = padding + (index / Math.max(points.length - 1, 1)) * innerWidth; + const y = padding + innerHeight - ((point.amount - min) / range) * innerHeight; + return { x, y }; + }); + + const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(' '); + const area = [ + `${coordinates[0].x},${height - padding}`, + ...coordinates.map(({ x, y }) => `${x},${y}`), + `${coordinates[coordinates.length - 1].x},${height - padding}`, + ].join(' '); + + return { polyline, area, min, max }; +} + +export default function CardDetailPriceHistoryTab({ + points = [], + priceHistoryLoading, + formatCurrency, +}) { + const chart = buildChartGeometry(points, 640, 220, 24); + + return ( +
+

+ Price History +

+ +
+

+ Price Trend +

+ + {priceHistoryLoading ? ( +
+

Loading price history…

+
+ ) : points.length === 0 ? ( +
+

+ No price history yet. Snapshots are recorded during catalog sync — check back after the next update. +

+
+ ) : ( +
+ + + + + + + + + + +
+ {points[0]?.date} + + Low {formatCurrency(chart.min)} · High {formatCurrency(chart.max)} + + {points[points.length - 1]?.date} +
+
+ )} +
+ + {points.length > 0 && ( +
+
+
+ +
+ Latest Price +
+
+ {formatCurrency(points[points.length - 1].amount)} +
+
+
+
+ )} +
+ ); +} diff --git a/components/card-detail/CardDetailPurchaseTab.js b/components/card-detail/CardDetailPurchaseTab.js new file mode 100644 index 0000000..7009c82 --- /dev/null +++ b/components/card-detail/CardDetailPurchaseTab.js @@ -0,0 +1,130 @@ +function formatUpdatedLabel(pricesUpdatedAt) { + if (!pricesUpdatedAt) return 'Price date unknown'; + const date = new Date(pricesUpdatedAt); + if (Number.isNaN(date.getTime())) return 'Price date unknown'; + return `Updated ${date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })}`; +} + +export function stalenessBadgeText({ stale, pricesUpdatedAt }) { + if (stale) return 'Prices may be outdated'; + return formatUpdatedLabel(pricesUpdatedAt); +} + +import CardDetailLocalStores from './CardDetailLocalStores'; + +export default function CardDetailPurchaseTab({ + card, + pricing, + pricingLoading, + formatCurrency, + nearbyStores = [], + nearbyStoresLoading = false, + nearbyStoresError = null, + nearbyStoresNoHomeZip = false, +}) { + const displayPrice = pricing?.prices?.current ?? pricing?.prices?.market ?? card?.current_price ?? null; + const foilPrice = pricing?.prices?.market ?? card?.market_price ?? null; + const badgeText = pricing ? stalenessBadgeText(pricing) : null; + + return ( +
+
+

+ Where to Buy +

+ {badgeText && ( + + {badgeText} + + )} +
+ +
+

+ Online Price Estimate +

+ {pricingLoading ? ( +

Loading prices…

+ ) : ( +
+
+

NM / Non-foil

+

+ {formatCurrency(displayPrice || 0)} +

+
+ {foilPrice != null && foilPrice !== displayPrice && ( +
+

Foil / Market

+

+ {formatCurrency(foilPrice)} +

+
+ )} +
+ )} +
+ +
+ {pricing?.outbound?.tcgplayer && ( + +
+ +
+

+ TCGPlayer +

+

+ View on TCGPlayer +

+
+
+
+ )} + {pricing?.outbound?.ebay && ( + +
+ +
+

+ eBay +

+

+ View on eBay +

+
+
+
+ )} +
+ + +
+ ); +} diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md index dad7a72..ab7aa8e 100644 --- a/docs/SCHEMA_MAP.md +++ b/docs/SCHEMA_MAP.md @@ -15,8 +15,8 @@ > historical effects into the migration history; until then this file > remains the curated reference for the full prod shape. > -> **Last reviewed:** 2026-06-14 against `migrations/` HEAD (through -> `1781442340000_cleanup-lorcana-orphans.js`). +> **Last reviewed:** 2026-09-08 against `migrations/` HEAD (through +> `1783000000001_add-pricing-schema.js`). ## Quick model groups @@ -29,6 +29,7 @@ | **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) | | **Decks** | `decks`, `deck_cards` | Playable deck definitions | | **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry | +| **Pricing** | `card_price_history`, `geo_zip_cache`, `geo_stores_cache` | Daily price snapshots + local store geocode caches | | **Invitations** | `invitations` (referenced; verify) | Pending share requests | ## Tables @@ -57,6 +58,7 @@ | `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` | | `language` | `VARCHAR(5)` default `'en'` | | | `is_pending` | `BOOLEAN` default `false` | Set by invitation flow before signup completes | +| `home_zip` | `VARCHAR(10)` | US zip for nearby game store discovery (`1783000000001`) | ### cards @@ -74,7 +76,8 @@ | `oracle_text` | `TEXT` | LARGE — never `SELECT *` | | `power`, `toughness` | `VARCHAR(10)` | MTG creatures | | `image_url`, `stock_image_url` | `TEXT` | | -| `current_price`, `market_price` | `DECIMAL(10,2)` | | +| `current_price`, `market_price` | `DECIMAL(10,2)` | MTG: `usd` / `usd_foil` from Scryfall | +| `prices_updated_at` | `TIMESTAMPTZ` | Last price ingest timestamp (`1783000000001`) | | `scryfall_id` | `VARCHAR(255) UNIQUE` | External dedupe key (Scryfall UUID, Pokémon TCG id, Lorcana `Unique_ID`) | | `verified` | `BOOLEAN` default `false` | Admin-edited cards | | `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** (`1781442330001_reconcile-cards-columns`) | @@ -284,6 +287,49 @@ Per-scan telemetry for the identify pipeline (layer 0 = visual kNN, layer 1 = Te Index: `idx_scan_attempts_user_created (user_id, created_at DESC)`. +### card_price_history + +Daily price snapshots written during catalog sync (Scryfall, TCGdex, Foilio). + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | `SERIAL PK` | | +| `card_id` | `INTEGER FK cards(id) ON DELETE CASCADE` | | +| `source` | `VARCHAR(32) NOT NULL` | `'scryfall' \| 'tcgdex' \| 'foilio'` | +| `price_type` | `VARCHAR(32)` default `'market'` | `'market' \| 'low' \| 'mid' \| 'high'` | +| `currency` | `VARCHAR(3)` default `'USD'` | | +| `amount` | `DECIMAL(10,2) NOT NULL` | | +| `recorded_at` | `DATE` default today | One row per card/source/type/currency/day | +| `created_at` | `TIMESTAMPTZ` default now | | + +**Unique:** `(card_id, source, price_type, currency, recorded_at)` +**Index:** `idx_card_price_history_card_date (card_id, recorded_at DESC)` + +### geo_zip_cache + +Permanent Nominatim geocode cache (zip → coordinates). + +| Column | Type | Notes | +| --- | --- | --- | +| `zip` | `VARCHAR(10) PK` | Normalized 5-digit US zip | +| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | | +| `display_name` | `TEXT` | OSM display name | +| `geocoded_at` | `TIMESTAMPTZ` default now | | + +### geo_stores_cache + +Overpass query results cache (7-day TTL enforced in application code). + +| Column | Type | Notes | +| --- | --- | --- | +| `cache_key` | `VARCHAR(64) PK` | sha256 of `{lat}:{lng}:{radiusMeters}` | +| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | Query center | +| `radius_meters` | `INTEGER NOT NULL` | | +| `stores` | `JSONB NOT NULL` | Parsed store list | +| `fetched_at` | `TIMESTAMPTZ` default now | | + +**Index:** `idx_geo_stores_cache_fetched (fetched_at)` + ### user_settings (split from users.*; verify which is canonical) Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled. diff --git a/lib/card-import/bulk-sync.js b/lib/card-import/bulk-sync.js index 61f3dd8..bbb1d3d 100644 --- a/lib/card-import/bulk-sync.js +++ b/lib/card-import/bulk-sync.js @@ -9,6 +9,7 @@ */ import { sql } from '../sql.js'; +import { recordPriceSnapshots } from '../pricing/price-history.js'; const BATCH_SIZE = 100; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -23,7 +24,7 @@ async function fetchBulkDownloadUrl() { return response.json(); } -function mapCard(card) { +export function mapCard(card) { let currentPrice = null; let marketPrice = null; if (card.prices) { @@ -65,14 +66,64 @@ function mapCard(card) { }; } +function hasPrice(row) { + return row.currentPrice != null || row.marketPrice != null; +} + +async function resolveCardIds(scryfallIds) { + if (scryfallIds.length === 0) return new Map(); + + const result = await sql.query( + 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)', + [scryfallIds] + ); + + return new Map(result.rows.map((row) => [row.scryfall_id, row.id])); +} + +async function recordBatchSnapshots(cards) { + const pricedCards = cards.filter(hasPrice); + if (pricedCards.length === 0) return 0; + + const idByScryfall = await resolveCardIds(pricedCards.map((row) => row.scryfallId)); + const snapshots = []; + + for (const row of pricedCards) { + const cardId = idByScryfall.get(row.scryfallId); + if (cardId == null) continue; + + if (row.currentPrice != null) { + snapshots.push({ + cardId, + source: 'scryfall', + priceType: 'market', + currency: 'USD', + amount: row.currentPrice, + }); + } + + if (row.marketPrice != null) { + snapshots.push({ + cardId, + source: 'scryfall', + priceType: 'foil', + currency: 'USD', + amount: row.marketPrice, + }); + } + } + + return recordPriceSnapshots(snapshots); +} + async function upsertBatch(cards) { - if (cards.length === 0) return 0; + if (cards.length === 0) return { upserted: 0, snapshotsWritten: 0 }; const columns = [ 'scryfall_id', 'oracle_id', 'illustration_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url', - 'current_price', 'market_price', 'keywords', 'legalities', 'flavor_text', 'artist', + 'current_price', 'market_price', 'prices_updated_at', 'keywords', 'legalities', 'flavor_text', 'artist', 'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified', ]; @@ -81,11 +132,12 @@ async function upsertBatch(cards) { let paramIdx = 1; for (const row of cards) { + const pricesUpdatedAt = hasPrice(row) ? new Date() : null; const rowValues = [ row.scryfallId, row.oracleId, row.illustrationId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity, 'MTG', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl, - row.currentPrice, row.marketPrice, row.keywords, row.legalities, row.flavorText, row.artist, + row.currentPrice, row.marketPrice, pricesUpdatedAt, row.keywords, row.legalities, row.flavorText, row.artist, row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true, ]; const rowP = rowValues.map(() => `$${paramIdx++}`); @@ -105,7 +157,9 @@ async function upsertBatch(cards) { values ); - return cards.length; + const snapshotsWritten = await recordBatchSnapshots(cards); + + return { upserted: cards.length, snapshotsWritten }; } /** @@ -126,6 +180,7 @@ export async function runBulkMtgSync() { const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital); let upserted = 0; + let snapshotsWritten = 0; let errors = 0; let batch = []; @@ -133,7 +188,9 @@ export async function runBulkMtgSync() { batch.push(mapCard(card)); if (batch.length >= BATCH_SIZE) { try { - upserted += await upsertBatch(batch); + const result = await upsertBatch(batch); + upserted += result.upserted; + snapshotsWritten += result.snapshotsWritten; } catch (err) { console.error('[bulk-sync] Batch error:', err.message); errors += batch.length; @@ -144,7 +201,9 @@ export async function runBulkMtgSync() { if (batch.length > 0) { try { - upserted += await upsertBatch(batch); + const result = await upsertBatch(batch); + upserted += result.upserted; + snapshotsWritten += result.snapshotsWritten; } catch (err) { console.error('[bulk-sync] Final batch error:', err.message); errors += batch.length; @@ -171,6 +230,7 @@ export async function runBulkMtgSync() { mode: 'bulk', totalInFile: mtgCards.length, upserted, + snapshotsWritten, errors, backfilledOracleId: backfill.rowCount || 0, updatedAt: meta.updated_at, diff --git a/lib/card-import/lorcana-bulk.js b/lib/card-import/lorcana-bulk.js index 33d3085..ed9cb8a 100644 --- a/lib/card-import/lorcana-bulk.js +++ b/lib/card-import/lorcana-bulk.js @@ -2,17 +2,24 @@ * Bulk catalog sync for Disney Lorcana via lorcana-api.com /bulk/cards. * * Single fetch returns all cards (~2.3k). Upserts keyed on scryfall_id (stores Unique_ID). + * Enriches prices from the Foilio hourly feed when available. */ import { sql as vercelSql } from '../sql.js'; +import { recordPriceSnapshots } from '../pricing/price-history.js'; +import { fetchFolioPriceIndex, matchFolioPrice } from '../pricing/foolio-client.js'; const BATCH_SIZE = 100; const BULK_URL = 'https://api.lorcana-api.com/bulk/cards'; +function hasPrice(row) { + return row.currentPrice != null || row.marketPrice != null; +} + /** * Map a Lorcana bulk API card object to cards-table insert shape. */ -export function mapLorcanaCard(card) { +export function mapLorcanaCard(card, pricing = null) { const colors = card.Color ? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean) : []; @@ -32,7 +39,7 @@ export function mapLorcanaCard(card) { const cost = card.Cost != null ? Number(card.Cost) : null; - return { + const row = { scryfallId: card.Unique_ID, name: card.Name, setName: card.Set_Name, @@ -54,14 +61,42 @@ export function mapLorcanaCard(card) { edhrecRank: card.Lore != null ? Number(card.Lore) : null, keywords: JSON.stringify(keywords), }; + + if (pricing) { + row.currentPrice = pricing.currentPrice; + row.marketPrice = pricing.marketPrice; + row.priceCurrency = pricing.currency ?? 'USD'; + if (hasPrice(row)) { + row.pricesUpdatedAt = new Date(); + } + } + + return row; +} + +export function enrichLorcanaRowWithFolio(row, folioIndex, stats) { + const pricing = matchFolioPrice(folioIndex, row); + if (!pricing || !hasPrice(pricing)) { + if (stats) stats.unmatchedCount += 1; + return row; + } + + if (stats) stats.pricesMatched += 1; + return { + ...row, + currentPrice: pricing.currentPrice, + marketPrice: pricing.marketPrice, + priceCurrency: pricing.currency, + pricesUpdatedAt: new Date(), + }; } export function buildUpsertQuery(batch) { const columns = [ 'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text', - 'power', 'toughness', 'image_url', 'stock_image_url', 'keywords', 'flavor_text', - 'artist', 'edhrec_rank', 'verified', + 'power', 'toughness', 'image_url', 'stock_image_url', 'current_price', 'market_price', + 'prices_updated_at', 'keywords', 'flavor_text', 'artist', 'edhrec_rank', 'verified', ]; const placeholders = []; @@ -74,6 +109,7 @@ export function buildUpsertQuery(batch) { row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity, 'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl, + row.currentPrice ?? null, row.marketPrice ?? null, row.pricesUpdatedAt ?? null, row.keywords, row.flavorText, row.artist, row.edhrecRank, true, ]; @@ -101,11 +137,70 @@ export function buildUpsertQuery(batch) { return { query, values }; } +async function resolveCardIds(sql, scryfallIds) { + if (scryfallIds.length === 0) return new Map(); + + const result = await sql.query( + 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)', + [scryfallIds] + ); + + return new Map(result.rows.map((row) => [row.scryfall_id, row.id])); +} + +async function recordBatchSnapshots(sql, batch) { + const pricedRows = batch.filter(hasPrice); + if (pricedRows.length === 0) return 0; + + const idByScryfall = await resolveCardIds(sql, pricedRows.map((row) => row.scryfallId)); + const snapshots = []; + + for (const row of pricedRows) { + const cardId = idByScryfall.get(row.scryfallId); + if (cardId == null) continue; + + const currency = row.priceCurrency ?? 'USD'; + + if (row.currentPrice != null) { + snapshots.push({ + cardId, + source: 'foilio', + priceType: 'market', + currency, + amount: row.currentPrice, + }); + } + + if (row.marketPrice != null && row.marketPrice !== row.currentPrice) { + snapshots.push({ + cardId, + source: 'foilio', + priceType: 'market', + currency, + amount: row.marketPrice, + }); + } else if (row.marketPrice != null && row.currentPrice == null) { + snapshots.push({ + cardId, + source: 'foilio', + priceType: 'market', + currency, + amount: row.marketPrice, + }); + } + } + + return recordPriceSnapshots(snapshots); +} + async function upsertBatch(sql, batch) { - if (batch.length === 0) return 0; + if (batch.length === 0) return { upserted: 0, snapshotsWritten: 0 }; + const { query, values } = buildUpsertQuery(batch); await sql.query(query, values); - return batch.length; + const snapshotsWritten = await recordBatchSnapshots(sql, batch); + + return { upserted: batch.length, snapshotsWritten }; } export async function fetchLorcanaBulkCards() { @@ -121,11 +216,21 @@ export async function fetchLorcanaBulkCards() { /** * Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting. * - * @param {{ sql?: { query: (q: string, v: unknown[]) => Promise }, dryRun?: boolean }} options + * @param {{ sql?: { query: (q: string, v: unknown[]) => Promise }, dryRun?: boolean, fetchFolioIndex?: () => Promise<{ byCardId: Map, byNameSet: Map }> }} options */ export async function runBulkLorcanaSync(options = {}) { const dryRun = options.dryRun ?? false; const sql = options.sql ?? vercelSql; + const fetchFolioIndex = options.fetchFolioIndex ?? fetchFolioPriceIndex; + + let folioIndex = { byCardId: new Map(), byNameSet: new Map() }; + if (!dryRun) { + try { + folioIndex = await fetchFolioIndex(); + } catch (error) { + console.warn('[lorcana-bulk] Foilio feed fetch failed:', error.message); + } + } const allCards = await fetchLorcanaBulkCards(); const lorcanaCards = allCards.filter( @@ -133,21 +238,33 @@ export async function runBulkLorcanaSync(options = {}) { ); let upserted = 0; + let snapshotsWritten = 0; let errors = 0; + let pricesMatched = 0; + let unmatchedCount = 0; let batch = []; + const matchStats = { + pricesMatched: 0, + unmatchedCount: 0, + }; + for (const card of lorcanaCards) { if (!card.Unique_ID) { errors += 1; continue; } - batch.push(mapLorcanaCard(card)); + let mapped = mapLorcanaCard(card); + mapped = enrichLorcanaRowWithFolio(mapped, folioIndex, matchStats); + batch.push(mapped); if (batch.length >= BATCH_SIZE) { try { if (!dryRun) { - upserted += await upsertBatch(sql, batch); + const result = await upsertBatch(sql, batch); + upserted += result.upserted; + snapshotsWritten += result.snapshotsWritten; } else { upserted += batch.length; } @@ -162,7 +279,9 @@ export async function runBulkLorcanaSync(options = {}) { if (batch.length > 0) { try { if (!dryRun) { - upserted += await upsertBatch(sql, batch); + const result = await upsertBatch(sql, batch); + upserted += result.upserted; + snapshotsWritten += result.snapshotsWritten; } else { upserted += batch.length; } @@ -172,12 +291,18 @@ export async function runBulkLorcanaSync(options = {}) { } } + pricesMatched = matchStats.pricesMatched; + unmatchedCount = matchStats.unmatchedCount; + const summary = { mode: 'bulk', game: 'lorcana', totalInFile: lorcanaCards.length, upserted, + snapshotsWritten, errors, + pricesMatched, + unmatchedCount, }; console.log('[lorcana-bulk]', JSON.stringify(summary)); diff --git a/lib/card-import/pokemon-bulk.js b/lib/card-import/pokemon-bulk.js index 6f2596c..0afd4ee 100644 --- a/lib/card-import/pokemon-bulk.js +++ b/lib/card-import/pokemon-bulk.js @@ -3,9 +3,17 @@ * * Fetches all sets from sets/en.json, then upserts every card in each set * (keyed on scryfall_id = external card id). Populates legalities from set metadata. + * Enriches prices from TCGdex when available. */ import { sql } from '../sql.js'; +import { recordPriceSnapshots } from '../pricing/price-history.js'; +import { + buildTcgdexPriceIndex, + fetchSetCards, + matchTcgdexCard, + parseTcgdexPricing, +} from '../pricing/tcgdex-client.js'; import { fetchPokemonSetCards, @@ -25,8 +33,12 @@ function getSetLegalities(setMeta) { return {}; } -export function mapPokemonBulkRow(mapped, setLegalities) { - return { +function hasPrice(row) { + return row.currentPrice != null || row.marketPrice != null; +} + +export function mapPokemonBulkRow(mapped, setLegalities, pricing = null) { + const row = { scryfallId: mapped.externalId, name: mapped.name, setName: mapped.setName, @@ -40,14 +52,29 @@ export function mapPokemonBulkRow(mapped, setLegalities) { imageUrl: mapped.imageSmall, stockImageUrl: mapped.imageLarge, legalities: JSON.stringify(setLegalities), + currentPrice: null, + marketPrice: null, + pricesUpdatedAt: null, + priceCurrency: 'USD', }; + + if (pricing) { + row.currentPrice = pricing.currentPrice; + row.marketPrice = pricing.marketPrice; + row.priceCurrency = pricing.currency ?? 'USD'; + if (hasPrice(row)) { + row.pricesUpdatedAt = new Date(); + } + } + + return row; } function buildUpsertQuery(batch) { const columns = [ 'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness', - 'image_url', 'stock_image_url', 'current_price', 'market_price', 'legalities', 'verified', + 'image_url', 'stock_image_url', 'current_price', 'market_price', 'prices_updated_at', 'legalities', 'verified', ]; const placeholders = []; @@ -73,8 +100,9 @@ function buildUpsertQuery(batch) { null, row.imageUrl, row.stockImageUrl, - null, - null, + row.currentPrice, + row.marketPrice, + row.pricesUpdatedAt, row.legalities, true, ]; @@ -103,6 +131,62 @@ function buildUpsertQuery(batch) { return { query, values }; } +async function resolveCardIds(scryfallIds, queryFn) { + if (scryfallIds.length === 0) return new Map(); + + const result = await queryFn( + 'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)', + [scryfallIds] + ); + + return new Map(result.rows.map((row) => [row.scryfall_id, row.id])); +} + +async function recordBatchSnapshots(batch, queryFn) { + const pricedRows = batch.filter(hasPrice); + if (pricedRows.length === 0) return 0; + + const idByScryfall = await resolveCardIds(pricedRows.map((row) => row.scryfallId), queryFn); + const snapshots = []; + + for (const row of pricedRows) { + const cardId = idByScryfall.get(row.scryfallId); + if (cardId == null) continue; + + const currency = row.priceCurrency ?? 'USD'; + + if (row.currentPrice != null) { + snapshots.push({ + cardId, + source: 'tcgdex', + priceType: 'market', + currency, + amount: row.currentPrice, + }); + } + + if (row.marketPrice != null && row.marketPrice !== row.currentPrice) { + snapshots.push({ + cardId, + source: 'tcgdex', + priceType: 'market', + currency, + amount: row.marketPrice, + }); + } else if (row.marketPrice != null && row.currentPrice == null) { + snapshots.push({ + cardId, + source: 'tcgdex', + priceType: 'market', + currency, + amount: row.marketPrice, + }); + } + } + + return recordPriceSnapshots(snapshots); +} + async function upsertBatch(batch, queryFn, stats) { if (batch.length === 0) return; @@ -115,6 +199,7 @@ async function upsertBatch(batch, queryFn, stats) { try { await queryFn(query, values); stats.upserted += batch.length; + stats.snapshotsWritten += await recordBatchSnapshots(batch, queryFn); } catch (error) { console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message); for (const row of batch) { @@ -122,6 +207,7 @@ async function upsertBatch(batch, queryFn, stats) { const { query: singleQ, values: singleV } = buildUpsertQuery([row]); await queryFn(singleQ, singleV); stats.upserted += 1; + stats.snapshotsWritten += await recordBatchSnapshots([row], queryFn); } catch (singleErr) { console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message); stats.errors += 1; @@ -130,13 +216,49 @@ async function upsertBatch(batch, queryFn, stats) { } } +function enrichRowWithTcgdex(row, tcgIndex, stats) { + const tcgCard = matchTcgdexCard(tcgIndex, row); + if (!tcgCard) { + stats.unmatchedCount += 1; + return row; + } + + const pricing = parseTcgdexPricing(tcgCard); + if (!hasPrice({ currentPrice: pricing.currentPrice, marketPrice: pricing.marketPrice })) { + stats.unmatchedCount += 1; + return row; + } + + stats.pricesMatched += 1; + return { + ...row, + currentPrice: pricing.currentPrice, + marketPrice: pricing.marketPrice, + priceCurrency: pricing.currency, + pricesUpdatedAt: new Date(), + }; +} + async function upsertSetCards(setMeta, cards, options, stats) { const setLegalities = getSetLegalities(setMeta); + let tcgIndex = { byNumber: new Map(), byName: new Map() }; + + if (!stats.dryRun && options.fetchTcgdexSetCards) { + try { + const tcgCards = await options.fetchTcgdexSetCards(setMeta.id); + tcgIndex = buildTcgdexPriceIndex(tcgCards); + } catch (error) { + console.warn(`[pokemon-bulk] TCGdex fetch failed for ${setMeta.id}:`, error.message); + } + } + let batch = []; for (const card of cards) { const mapped = mapGithubCardForInsert(card, setMeta); - batch.push(mapPokemonBulkRow(mapped, setLegalities)); + let row = mapPokemonBulkRow(mapped, setLegalities); + row = enrichRowWithTcgdex(row, tcgIndex, stats); + batch.push(row); if (batch.length >= options.batchSize) { await upsertBatch(batch, options.queryFn, stats); @@ -158,6 +280,7 @@ export async function runBulkPokemonSync(options = {}) { const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true'; const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS; const queryFn = options.query ?? ((query, values) => sql.query(query, values)); + const fetchTcgdexSetCards = options.fetchTcgdexSetCards ?? fetchSetCards; const sets = await fetchPokemonSets(); console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`); @@ -168,6 +291,9 @@ export async function runBulkPokemonSync(options = {}) { skipped: 0, errors: 0, setsProcessed: 0, + pricesMatched: 0, + unmatchedCount: 0, + snapshotsWritten: 0, }; for (let index = 0; index < sets.length; index += 1) { @@ -186,7 +312,7 @@ export async function runBulkPokemonSync(options = {}) { if (cards.length === 0) { console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`); } else { - await upsertSetCards(setMeta, cards, { batchSize, queryFn }, stats); + await upsertSetCards(setMeta, cards, { batchSize, queryFn, fetchTcgdexSetCards }, stats); console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`); } } catch (error) { @@ -207,6 +333,9 @@ export async function runBulkPokemonSync(options = {}) { upserted: stats.upserted, skipped: stats.skipped, errors: stats.errors, + pricesMatched: stats.pricesMatched, + unmatchedCount: stats.unmatchedCount, + snapshotsWritten: stats.snapshotsWritten, dryRun, }; diff --git a/lib/pricing/card-price.js b/lib/pricing/card-price.js new file mode 100644 index 0000000..7d9a524 --- /dev/null +++ b/lib/pricing/card-price.js @@ -0,0 +1,31 @@ +/** Hours after which a stored price is considered stale for live refresh. */ +export const PRICE_STALE_HOURS = 24; + +/** + * Resolve the unit price for a owned card row (foil-aware). + * MTG: current_price = nonfoil USD, market_price = foil USD (Scryfall mapping). + * + * @param {{ currentPrice?: number | null, marketPrice?: number | null, isFoil?: boolean }} params + * @returns {number | null} + */ +export function resolveUnitPrice({ currentPrice, marketPrice, isFoil = false }) { + const current = currentPrice != null ? Number(currentPrice) : null; + const market = marketPrice != null ? Number(marketPrice) : null; + + if (isFoil) { + return market ?? current ?? null; + } + return current ?? market ?? null; +} + +/** + * @param {Date | string | null | undefined} pricesUpdatedAt + * @returns {boolean} + */ +export function isPriceStale(pricesUpdatedAt) { + if (!pricesUpdatedAt) return true; + const updated = pricesUpdatedAt instanceof Date ? pricesUpdatedAt : new Date(pricesUpdatedAt); + if (Number.isNaN(updated.getTime())) return true; + const ageMs = Date.now() - updated.getTime(); + return ageMs > PRICE_STALE_HOURS * 60 * 60 * 1000; +} diff --git a/lib/pricing/collection-valuation.js b/lib/pricing/collection-valuation.js new file mode 100644 index 0000000..74084d1 --- /dev/null +++ b/lib/pricing/collection-valuation.js @@ -0,0 +1,19 @@ +/** + * SQL expression for foil-aware unit price on user-owned rows. + * + * @param {{ cardAlias?: string, userCardAlias?: string }} [options] + * @returns {string} + */ +export function effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' } = {}) { + return `CASE WHEN ${userCardAlias}.is_foil THEN ${cardAlias}.market_price ELSE COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price) END`; +} + +/** + * SQL expression for list/catalog unit price (no per-row foil flag on collection_cards). + * + * @param {{ cardAlias?: string }} [options] + * @returns {string} + */ +export function listUnitPriceExpr({ cardAlias = 'c' } = {}) { + return `COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price)`; +} diff --git a/lib/pricing/foolio-client.js b/lib/pricing/foolio-client.js new file mode 100644 index 0000000..02a2320 --- /dev/null +++ b/lib/pricing/foolio-client.js @@ -0,0 +1,120 @@ +const DEFAULT_FOILIO_FEED_URL = 'https://foilio.net/api/prices/feed?game=lorcana'; + +export function getFolioFeedUrl() { + return process.env.FOILIO_PRICES_FEED_URL ?? DEFAULT_FOILIO_FEED_URL; +} + +export function normalizeFolioName(name) { + return String(name ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function parseAmount(value) { + if (value == null || value === '') return null; + const amount = Number(value); + return Number.isFinite(amount) ? amount : null; +} + +/** + * Parse a Foilio feed row into current/market prices. + * Prefer USD fields; fall back to EUR with currency noted for snapshots. + */ +export function parseFolioPriceEntry(entry) { + const usdLow = + parseAmount(entry.usd_low) + ?? parseAmount(entry.low_usd) + ?? parseAmount(entry.price_usd) + ?? parseAmount(entry.usd); + const usdMarket = + parseAmount(entry.usd_market) + ?? parseAmount(entry.market_usd) + ?? parseAmount(entry.market) + ?? parseAmount(entry.price); + + if (usdLow != null || usdMarket != null) { + return { + currentPrice: usdLow ?? usdMarket, + marketPrice: usdMarket ?? usdLow, + currency: 'USD', + }; + } + + const eurLow = parseAmount(entry.eur_low) ?? parseAmount(entry.low_eur); + const eurMarket = parseAmount(entry.eur_market) ?? parseAmount(entry.eur) ?? parseAmount(entry.price_eur); + if (eurLow != null || eurMarket != null) { + return { + currentPrice: eurLow ?? eurMarket, + marketPrice: eurMarket ?? eurLow, + currency: 'EUR', + }; + } + + return { currentPrice: null, marketPrice: null, currency: 'USD' }; +} + +function feedEntries(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.prices)) return payload.prices; + if (Array.isArray(payload?.cards)) return payload.cards; + if (Array.isArray(payload?.data)) return payload.data; + return []; +} + +/** + * Build lookup indexes from a Foilio feed payload. + */ +export function buildFolioPriceIndex(payload) { + const byCardId = new Map(); + const byNameSet = new Map(); + + for (const entry of feedEntries(payload)) { + const parsed = parseFolioPriceEntry(entry); + if (parsed.currentPrice == null && parsed.marketPrice == null) continue; + + const cardId = entry.card_id ?? entry.cardId ?? entry.id ?? entry.unique_id ?? entry.Unique_ID; + if (cardId) { + byCardId.set(String(cardId), parsed); + } + + const setCode = entry.set ?? entry.set_id ?? entry.setId ?? entry.Set_ID; + const name = entry.name ?? entry.Name; + if (setCode && name) { + const key = `${normalizeFolioName(name)}|${String(setCode).trim()}`; + if (!byNameSet.has(key)) { + byNameSet.set(key, parsed); + } + } + } + + return { byCardId, byNameSet }; +} + +/** + * Match a Lorcana catalog row against the Foilio price index. + */ +export function matchFolioPrice(index, row) { + const byId = index.byCardId.get(String(row.scryfallId)); + if (byId) return byId; + + const key = `${normalizeFolioName(row.name)}|${String(row.setCode ?? '').trim()}`; + return index.byNameSet.get(key) ?? null; +} + +/** + * Fetch the Foilio Lorcana price feed once per sync run. + */ +export async function fetchFolioPriceFeed() { + const response = await fetch(getFolioFeedUrl(), { + headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' }, + }); + + if (!response.ok) { + throw new Error(`Foilio feed error: ${response.status}`); + } + + return response.json(); +} + +export async function fetchFolioPriceIndex() { + const payload = await fetchFolioPriceFeed(); + return buildFolioPriceIndex(payload); +} diff --git a/lib/pricing/geo-cache.js b/lib/pricing/geo-cache.js new file mode 100644 index 0000000..44b14eb --- /dev/null +++ b/lib/pricing/geo-cache.js @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto'; + +import { sql } from '../sql.js'; +import { geocodeUsZip } from './nominatim.js'; +import { fetchGameStores } from './overpass.js'; + +export const STORES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * @param {number} lat + * @param {number} lng + * @param {number} radiusMeters + * @returns {string} + */ +export function buildStoresCacheKey(lat, lng, radiusMeters) { + const payload = `${lat.toFixed(5)}:${lng.toFixed(5)}:${radiusMeters}`; + return createHash('sha256').update(payload).digest('hex').slice(0, 64); +} + +/** + * @param {Date | string | null | undefined} fetchedAt + * @param {number} [ttlMs] + * @returns {boolean} + */ +export function isStoresCacheFresh(fetchedAt, ttlMs = STORES_CACHE_TTL_MS) { + if (!fetchedAt) return false; + const ts = fetchedAt instanceof Date ? fetchedAt.getTime() : new Date(fetchedAt).getTime(); + if (!Number.isFinite(ts)) return false; + return Date.now() - ts < ttlMs; +} + +/** + * @param {string} zip + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise<{ lat: number, lng: number, displayName: string, cached: boolean } | null>} + */ +export async function getZipCoords(zip, options = {}) { + const cached = await sql` + SELECT lat, lng, display_name + FROM geo_zip_cache + WHERE zip = ${zip} + LIMIT 1 + `; + + if (cached.rows.length > 0) { + const row = cached.rows[0]; + return { + lat: Number(row.lat), + lng: Number(row.lng), + displayName: row.display_name ?? zip, + cached: true, + }; + } + + const geocoded = await geocodeUsZip(zip, options); + if (!geocoded) { + return null; + } + + await sql` + INSERT INTO geo_zip_cache (zip, lat, lng, display_name) + VALUES (${zip}, ${geocoded.lat}, ${geocoded.lng}, ${geocoded.displayName}) + ON CONFLICT (zip) DO UPDATE SET + lat = EXCLUDED.lat, + lng = EXCLUDED.lng, + display_name = EXCLUDED.display_name, + geocoded_at = CURRENT_TIMESTAMP + `; + + return { ...geocoded, cached: false }; +} + +/** + * @param {number} lat + * @param {number} lng + * @param {number} radiusMeters + * @param {{ fetchImpl?: typeof fetch, forceRefresh?: boolean }} [options] + * @returns {Promise<{ stores: unknown[], cached: boolean }>} + */ +export async function getStoresNear(lat, lng, radiusMeters, options = {}) { + const cacheKey = buildStoresCacheKey(lat, lng, radiusMeters); + + if (!options.forceRefresh) { + const cached = await sql` + SELECT stores, fetched_at + FROM geo_stores_cache + WHERE cache_key = ${cacheKey} + LIMIT 1 + `; + + if (cached.rows.length > 0 && isStoresCacheFresh(cached.rows[0].fetched_at)) { + return { stores: cached.rows[0].stores, cached: true }; + } + } + + const stores = await fetchGameStores(lat, lng, radiusMeters, options); + + await sql` + INSERT INTO geo_stores_cache (cache_key, lat, lng, radius_meters, stores, fetched_at) + VALUES (${cacheKey}, ${lat}, ${lng}, ${radiusMeters}, ${JSON.stringify(stores)}::jsonb, CURRENT_TIMESTAMP) + ON CONFLICT (cache_key) DO UPDATE SET + lat = EXCLUDED.lat, + lng = EXCLUDED.lng, + radius_meters = EXCLUDED.radius_meters, + stores = EXCLUDED.stores, + fetched_at = CURRENT_TIMESTAMP + `; + + return { stores, cached: false }; +} diff --git a/lib/pricing/nominatim.js b/lib/pricing/nominatim.js new file mode 100644 index 0000000..e2913ad --- /dev/null +++ b/lib/pricing/nominatim.js @@ -0,0 +1,69 @@ +const DEFAULT_NOMINATIM_BASE = 'https://nominatim.openstreetmap.org'; +const MIN_REQUEST_INTERVAL_MS = 1000; + +const ALLOWED_HOSTS = new Set(['nominatim.openstreetmap.org']); + +let lastRequestAt = 0; + +function assertAllowedHost(url) { + const host = new URL(url).hostname.toLowerCase(); + if (!ALLOWED_HOSTS.has(host)) { + throw new Error(`[nominatim] Host not allowlisted: ${host}`); + } +} + +function getUserAgent() { + return process.env.NOMINATIM_USER_AGENT ?? 'DeckHearth/1.0'; +} + +async function throttle() { + const now = Date.now(); + const elapsed = now - lastRequestAt; + if (elapsed < MIN_REQUEST_INTERVAL_MS) { + await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed)); + } + lastRequestAt = Date.now(); +} + +/** + * @param {string} zip Five-digit US zip code. + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise<{ lat: number, lng: number, displayName: string } | null>} + */ +export async function geocodeUsZip(zip, { fetchImpl = fetch } = {}) { + const base = process.env.NOMINATIM_BASE_URL ?? DEFAULT_NOMINATIM_BASE; + const url = new URL('/search', base); + url.searchParams.set('postalcode', zip); + url.searchParams.set('country', 'US'); + url.searchParams.set('format', 'json'); + url.searchParams.set('limit', '1'); + + assertAllowedHost(url.toString()); + await throttle(); + + const response = await fetchImpl(url.toString(), { + headers: { 'User-Agent': getUserAgent() }, + }); + + if (!response.ok) { + throw new Error(`[nominatim] HTTP ${response.status}`); + } + + const results = await response.json(); + if (!Array.isArray(results) || results.length === 0) { + return null; + } + + const hit = results[0]; + const lat = Number(hit.lat); + const lng = Number(hit.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return null; + } + + return { + lat, + lng, + displayName: hit.display_name ?? `${zip}, US`, + }; +} diff --git a/lib/pricing/outbound-links.js b/lib/pricing/outbound-links.js new file mode 100644 index 0000000..4e4045f --- /dev/null +++ b/lib/pricing/outbound-links.js @@ -0,0 +1,32 @@ +/** + * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card + * @returns {string} + */ +export function buildTcgplayerUrl(card) { + const name = card.name || ''; + const game = (card.game || 'mtg').toLowerCase(); + return `https://www.tcgplayer.com/search/${game}/product?productName=${encodeURIComponent(name)}`; +} + +/** + * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card + * @returns {string} + */ +export function buildEbayUrl(card) { + const name = card.name || ''; + const game = card.game || ''; + const setName = card.set_name || card.setName || ''; + const query = [name, setName, game].filter(Boolean).join(' '); + return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(query)}`; +} + +/** + * @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card + * @returns {{ tcgplayer: string, ebay: string }} + */ +export function buildOutboundLinks(card) { + return { + tcgplayer: buildTcgplayerUrl(card), + ebay: buildEbayUrl(card), + }; +} diff --git a/lib/pricing/overpass.js b/lib/pricing/overpass.js new file mode 100644 index 0000000..3659394 --- /dev/null +++ b/lib/pricing/overpass.js @@ -0,0 +1,178 @@ +const DEFAULT_OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; +const METERS_PER_MILE = 1609.344; +const MIN_RADIUS_MILES = 1; +const MAX_RADIUS_MILES = 50; +const DEFAULT_RADIUS_MILES = 25; + +const ALLOWED_HOSTS = new Set(['overpass-api.de', 'overpass.kumi.systems']); + +function assertAllowedHost(url) { + const host = new URL(url).hostname.toLowerCase(); + if (!ALLOWED_HOSTS.has(host)) { + throw new Error(`[overpass] Host not allowlisted: ${host}`); + } +} + +/** + * @param {number} miles + * @returns {number} + */ +export function clampRadiusMiles(miles) { + const value = Number(miles); + if (!Number.isFinite(value)) { + return DEFAULT_RADIUS_MILES; + } + return Math.min(MAX_RADIUS_MILES, Math.max(MIN_RADIUS_MILES, value)); +} + +/** + * @param {number} miles + * @returns {number} + */ +export function milesToMeters(miles) { + return Math.round(clampRadiusMiles(miles) * METERS_PER_MILE); +} + +/** + * Build Overpass QL from validated numeric inputs only — never user strings. + * @param {{ lat: number, lng: number, radiusMeters: number }} params + * @returns {string} + */ +export function buildOverpassQuery({ lat, lng, radiusMeters }) { + const safeLat = Number(lat); + const safeLng = Number(lng); + const safeRadius = Math.round(Number(radiusMeters)); + + if (!Number.isFinite(safeLat) || !Number.isFinite(safeLng) || !Number.isFinite(safeRadius)) { + throw new Error('[overpass] Invalid numeric query parameters'); + } + if (safeRadius < 1 || safeRadius > Math.round(MAX_RADIUS_MILES * METERS_PER_MILE)) { + throw new Error('[overpass] Radius out of bounds'); + } + + return `[out:json][timeout:25]; +( + node["shop"="games"](around:${safeRadius},${safeLat},${safeLng}); + node["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng}); + way["shop"="games"](around:${safeRadius},${safeLat},${safeLng}); + way["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng}); +); +out center tags;`; +} + +function formatAddress(tags) { + const parts = [ + tags['addr:housenumber'], + tags['addr:street'], + tags['addr:city'], + tags['addr:state'], + tags['addr:postcode'], + ].filter(Boolean); + if (parts.length > 0) { + return parts.join(', '); + } + return tags['addr:full'] ?? ''; +} + +function elementCoords(element) { + if (element.type === 'node' && element.lat != null && element.lon != null) { + return { lat: Number(element.lat), lng: Number(element.lon) }; + } + if (element.center?.lat != null && element.center?.lon != null) { + return { lat: Number(element.center.lat), lng: Number(element.center.lon) }; + } + return null; +} + +function osmUrl(element) { + const type = element.type === 'node' ? 'node' : 'way'; + return `https://www.openstreetmap.org/${type}/${element.id}`; +} + +/** + * @param {unknown} data + * @param {{ originLat: number, originLng: number }} origin + * @returns {Array<{ + * id: string, + * name: string, + * address: string, + * lat: number, + * lng: number, + * distanceMiles: number, + * phone: string | null, + * website: string | null, + * osmUrl: string, + * }>} + */ +export function parseOverpassResponse(data, { originLat, originLng }) { + const elements = data?.elements; + if (!Array.isArray(elements)) { + return []; + } + + const stores = []; + + for (const element of elements) { + const tags = element.tags ?? {}; + const name = tags.name; + if (!name) continue; + + const coords = elementCoords(element); + if (!coords || !Number.isFinite(coords.lat) || !Number.isFinite(coords.lng)) { + continue; + } + + stores.push({ + id: `${element.type}/${element.id}`, + name, + address: formatAddress(tags), + lat: coords.lat, + lng: coords.lng, + distanceMiles: haversineMiles(originLat, originLng, coords.lat, coords.lng), + phone: tags.phone ?? tags['contact:phone'] ?? null, + website: tags.website ?? tags['contact:website'] ?? null, + osmUrl: osmUrl(element), + }); + } + + stores.sort((a, b) => a.distanceMiles - b.distanceMiles); + return stores; +} + +/** + * @param {number} lat + * @param {number} lng + * @param {number} radiusMeters + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise>} + */ +export async function fetchGameStores(lat, lng, radiusMeters, { fetchImpl = fetch } = {}) { + const endpoint = process.env.OVERPASS_URL ?? DEFAULT_OVERPASS_URL; + assertAllowedHost(endpoint); + + const query = buildOverpassQuery({ lat, lng, radiusMeters }); + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + }); + + if (!response.ok) { + throw new Error(`[overpass] HTTP ${response.status}`); + } + + const data = await response.json(); + return parseOverpassResponse(data, { originLat: lat, originLng: lng }); +} + +function haversineMiles(lat1, lng1, lat2, lng2) { + const toRad = (deg) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const earthRadiusMiles = 3958.8; + return Math.round(c * earthRadiusMiles * 10) / 10; +} diff --git a/lib/pricing/price-history.js b/lib/pricing/price-history.js new file mode 100644 index 0000000..f9132bd --- /dev/null +++ b/lib/pricing/price-history.js @@ -0,0 +1,78 @@ +import { sql } from '../sql.js'; + +/** + * @typedef {Object} PriceSnapshotRow + * @property {number} cardId + * @property {string} source + * @property {string} [priceType] + * @property {string} [currency] + * @property {number} amount + * @property {string | Date} [recordedAt] + */ + +/** + * Upsert daily price snapshots (idempotent per card/source/type/currency/day). + * + * @param {PriceSnapshotRow[]} rows + * @returns {Promise} rows written + */ +export async function recordPriceSnapshots(rows) { + if (!rows?.length) return 0; + + let written = 0; + for (const row of rows) { + if (row.cardId == null || row.amount == null || Number.isNaN(Number(row.amount))) { + continue; + } + + const priceType = row.priceType ?? 'market'; + const currency = row.currency ?? 'USD'; + const recordedAt = + row.recordedAt instanceof Date + ? row.recordedAt.toISOString().slice(0, 10) + : row.recordedAt ?? new Date().toISOString().slice(0, 10); + + await sql` + INSERT INTO card_price_history (card_id, source, price_type, currency, amount, recorded_at) + VALUES ( + ${row.cardId}, + ${row.source}, + ${priceType}, + ${currency}, + ${Number(row.amount)}, + ${recordedAt} + ) + ON CONFLICT (card_id, source, price_type, currency, recorded_at) + DO UPDATE SET amount = EXCLUDED.amount + `; + written += 1; + } + + return written; +} + +/** + * @param {number} cardId + * @param {{ days?: number }} [options] + * @returns {Promise>} + */ +export async function getPriceHistoryPoints(cardId, { days = 90 } = {}) { + const cappedDays = Math.min(Math.max(Number(days) || 90, 1), 365); + const result = await sql` + SELECT recorded_at, amount, source, price_type, currency + FROM card_price_history + WHERE card_id = ${cardId} + AND recorded_at >= CURRENT_DATE - ${cappedDays}::integer + ORDER BY recorded_at ASC + `; + + return result.rows.map((row) => ({ + date: row.recorded_at instanceof Date + ? row.recorded_at.toISOString().slice(0, 10) + : String(row.recorded_at).slice(0, 10), + amount: parseFloat(row.amount), + source: row.source, + priceType: row.price_type, + currency: row.currency, + })); +} diff --git a/lib/pricing/pricing-service.js b/lib/pricing/pricing-service.js new file mode 100644 index 0000000..1f24b33 --- /dev/null +++ b/lib/pricing/pricing-service.js @@ -0,0 +1,256 @@ +import Redis from 'ioredis'; +import { sql } from '../sql.js'; +import { isPriceStale } from './card-price.js'; +import { buildOutboundLinks } from './outbound-links.js'; +import { recordPriceSnapshots } from './price-history.js'; +import { getTcgdexBaseUrl, parseTcgdexPricing } from './tcgdex-client.js'; +import { fetchFolioPriceIndex, matchFolioPrice } from './foolio-client.js'; + +const REFRESH_LOCK_TTL_SEC = 60; +const REFRESH_LOCK_PREFIX = 'deckhearth:pricing-refresh:'; + +let redisClient = null; + +function getRedis() { + const url = process.env.REDIS_URL; + if (!url) return null; + if (!redisClient) { + redisClient = new Redis(url, { + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + }); + } + return redisClient; +} + +/** + * @param {number} cardId + * @returns {Promise} + */ +async function acquireRefreshLock(cardId) { + const redis = getRedis(); + if (!redis) return true; + try { + const result = await redis.set( + `${REFRESH_LOCK_PREFIX}${cardId}`, + '1', + 'EX', + REFRESH_LOCK_TTL_SEC, + 'NX' + ); + return result === 'OK'; + } catch (err) { + console.error('[pricing-service] lock acquire failed', err); + return false; + } +} + +/** + * @param {number} cardId + */ +async function releaseRefreshLock(cardId) { + const redis = getRedis(); + if (!redis) return; + try { + await redis.del(`${REFRESH_LOCK_PREFIX}${cardId}`); + } catch (err) { + console.error('[pricing-service] lock release failed', err); + } +} + +/** + * @param {string} scryfallId + */ +async function fetchScryfallPrices(scryfallId) { + const response = await fetch(`https://api.scryfall.com/cards/${scryfallId}`, { + headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Scryfall API error: ${response.status}`); + } + const card = await response.json(); + let currentPrice = null; + let marketPrice = null; + if (card.prices) { + currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null; + marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null; + } + return { currentPrice, marketPrice, currency: 'USD' }; +} + +/** + * @param {{ set_code?: string, card_number?: string, name?: string }} card + */ +async function fetchPokemonPrices(card) { + const setCode = card.set_code; + if (!setCode) return null; + + const base = getTcgdexBaseUrl(); + if (!card.card_number) return null; + + const paths = [ + `/cards/${encodeURIComponent(setCode)}-${encodeURIComponent(card.card_number)}`, + ]; + + for (const path of paths) { + try { + const response = await fetch(`${base}${path}`, { + headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' }, + }); + if (!response.ok) continue; + const tcgCard = await response.json(); + const parsed = parseTcgdexPricing(tcgCard); + if (parsed.currentPrice != null || parsed.marketPrice != null) { + return { + currentPrice: parsed.currentPrice, + marketPrice: parsed.marketPrice, + currency: parsed.currency ?? 'USD', + }; + } + } catch { + // try next path + } + } + + return null; +} + +/** + * @param {{ name?: string, set_code?: string, scryfall_id?: string }} card + */ +async function fetchLorcanaPrices(card) { + const index = await fetchFolioPriceIndex(); + const matched = matchFolioPrice(index, { + name: card.name, + setCode: card.set_code, + scryfallId: card.scryfall_id, + }); + if (!matched) return null; + return { + currentPrice: matched.currentPrice, + marketPrice: matched.marketPrice, + currency: matched.currency ?? 'USD', + }; +} + +/** + * @param {Record} card + */ +async function refreshLivePrices(card) { + const game = String(card.game || '').toLowerCase(); + let prices = null; + + if (game === 'mtg' && card.scryfall_id) { + prices = await fetchScryfallPrices(card.scryfall_id); + } else if (game === 'pokemon') { + prices = await fetchPokemonPrices(card); + } else if (game === 'lorcana') { + prices = await fetchLorcanaPrices(card); + } + + if (!prices || (prices.currentPrice == null && prices.marketPrice == null)) { + return null; + } + + await sql` + UPDATE cards + SET + current_price = ${prices.currentPrice}, + market_price = ${prices.marketPrice}, + prices_updated_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${card.id} + `; + + const snapshots = []; + if (prices.currentPrice != null) { + snapshots.push({ + cardId: card.id, + source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio', + priceType: 'market', + currency: prices.currency ?? 'USD', + amount: prices.currentPrice, + }); + } + if (prices.marketPrice != null && prices.marketPrice !== prices.currentPrice) { + snapshots.push({ + cardId: card.id, + source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio', + priceType: 'foil', + currency: prices.currency ?? 'USD', + amount: prices.marketPrice, + }); + } + if (snapshots.length) { + await recordPriceSnapshots(snapshots); + } + + return prices; +} + +/** + * @param {number} cardId + * @returns {Promise} + */ +export async function getCardPricing(cardId) { + const result = await sql` + SELECT + id, name, game, set_name, set_code, card_number, + current_price, market_price, prices_updated_at, scryfall_id + FROM cards + WHERE id = ${cardId} + `; + + if (result.rows.length === 0) { + return null; + } + + let card = result.rows[0]; + let stale = isPriceStale(card.prices_updated_at); + + if (stale) { + const gotLock = await acquireRefreshLock(cardId); + if (gotLock) { + try { + await refreshLivePrices(card).catch((err) => { + console.error('[pricing-service] live refresh failed', err); + }); + + const refreshed = await sql` + SELECT + id, name, game, set_name, set_code, card_number, + current_price, market_price, prices_updated_at, scryfall_id + FROM cards + WHERE id = ${cardId} + `; + if (refreshed.rows.length) { + card = refreshed.rows[0]; + stale = isPriceStale(card.prices_updated_at); + } + } finally { + await releaseRefreshLock(cardId); + } + } + } + + const current = card.current_price != null ? parseFloat(card.current_price) : null; + const market = card.market_price != null ? parseFloat(card.market_price) : null; + + return { + prices: { + current: Number.isFinite(current) ? current : null, + market: Number.isFinite(market) ? market : null, + currency: 'USD', + }, + pricesUpdatedAt: card.prices_updated_at + ? new Date(card.prices_updated_at).toISOString() + : null, + stale, + outbound: buildOutboundLinks(card), + }; +} diff --git a/lib/pricing/tcgdex-client.js b/lib/pricing/tcgdex-client.js new file mode 100644 index 0000000..b212b55 --- /dev/null +++ b/lib/pricing/tcgdex-client.js @@ -0,0 +1,162 @@ +const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function getTcgdexBaseUrl() { + return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL; +} + +function tcgplayerVariant(tcgplayer) { + if (!tcgplayer || typeof tcgplayer !== 'object') return null; + + const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition']; + for (const key of preferredKeys) { + const variant = tcgplayer[key]; + if (variant && typeof variant === 'object') { + return variant; + } + } + + return Object.values(tcgplayer).find( + (value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value) + ) ?? null; +} + +/** + * Parse TCGdex pricing into Deck Hearth card price fields. + * USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing. + */ +export function parseTcgdexPricing(card) { + let currentPrice = null; + let marketPrice = null; + let currency = 'USD'; + + const variant = tcgplayerVariant(card.pricing?.tcgplayer); + if (variant) { + const low = variant.lowPrice ?? variant.directLowPrice; + const market = variant.marketPrice ?? variant.midPrice; + currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null); + marketPrice = market != null ? Number(market) : currentPrice; + } + + // Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable. + if (currentPrice == null && marketPrice == null) { + const cardmarket = card.pricing?.cardmarket; + const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low; + if (eur != null) { + marketPrice = Number(eur); + currency = 'EUR'; + } + } + + if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null; + if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null; + + return { currentPrice, marketPrice, currency }; +} + +export function normalizeTcgdexName(name) { + return String(name ?? '').trim().toLowerCase(); +} + +function cardNumberKeys(card) { + const keys = new Set(); + const localId = card.localId ?? card.id?.split('-').pop(); + if (localId != null && localId !== '') { + keys.add(String(localId).trim().toLowerCase()); + const official = card.set?.cardCount?.official; + if (official) { + keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase()); + } + } + return keys; +} + +/** + * Build lookup indexes for matching DB rows by set_code + card_number or name. + */ +export function buildTcgdexPriceIndex(cards) { + const byNumber = new Map(); + const byName = new Map(); + + for (const card of cards) { + for (const key of cardNumberKeys(card)) { + if (!byNumber.has(key)) { + byNumber.set(key, card); + } + } + + const nameKey = normalizeTcgdexName(card.name); + if (!byName.has(nameKey)) { + byName.set(nameKey, []); + } + byName.get(nameKey).push(card); + } + + return { byNumber, byName }; +} + +/** + * Match a catalog row to a TCGdex card within a set. + */ +export function matchTcgdexCard(index, row) { + const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase(); + if (cardNumber) { + const direct = index.byNumber.get(cardNumber); + if (direct) return direct; + + const slashIndex = cardNumber.indexOf('/'); + if (slashIndex !== -1) { + const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10)); + if (numerator !== 'NaN') { + const byNumerator = index.byNumber.get(numerator); + if (byNumerator) return byNumerator; + } + } + } + + const nameKey = normalizeTcgdexName(row.name); + const candidates = index.byName.get(nameKey) ?? []; + return candidates.length === 1 ? candidates[0] : null; +} + +async function fetchTcgdexJson(path) { + const response = await fetch(`${getTcgdexBaseUrl()}${path}`, { + headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' }, + }); + + if (!response.ok) { + throw new Error(`TCGdex API error: ${response.status} for ${path}`); + } + + return response.json(); +} + +/** + * Fetch all cards for a Pokémon set, including pricing when available. + */ +export async function fetchSetCards(setId) { + const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`); + const summaries = Array.isArray(setData?.cards) ? setData.cards : []; + + const cards = []; + for (const summary of summaries) { + if (!summary?.id) continue; + + if (summary.pricing) { + cards.push(summary); + continue; + } + + try { + const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`); + cards.push(fullCard); + } catch { + cards.push(summary); + } + } + + return cards; +} + +export { delay }; diff --git a/lib/rate-limit.js b/lib/rate-limit.js index 7f9258b..84d4546 100644 --- a/lib/rate-limit.js +++ b/lib/rate-limit.js @@ -9,6 +9,8 @@ const LIMITER_CONFIG = { import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' }, // One camera verify may escalate L0→L2; vision path is the expensive step. scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' }, + stores: { limit: 1, durationSec: 60 * 60, prefix: 'deckhearth:stores' }, + storesIp: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:stores-ip' }, }; let cached = null; @@ -122,3 +124,11 @@ export async function checkImportRateLimit(req, userId) { export async function checkScanRateLimit(req, userId) { return check('scan', extractUserIdentifier(userId)); } + +export async function checkStoresRateLimit(req, userId) { + const userResult = await check('stores', extractUserIdentifier(userId)); + if (!userResult.allowed) { + return userResult; + } + return check('storesIp', extractIpIdentifier(req)); +} diff --git a/lib/sql.js b/lib/sql.js index 2817f96..f8a5ced 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -65,6 +65,23 @@ export async function sql(strings, ...values) { }; } +/** + * Parameterized raw SQL for bulk import upserts (legacy `$1` placeholders). + * @param {string} text + * @param {unknown[]} [values] + */ +export async function sqlQuery(text, values = []) { + const pg = getClient(); + const result = await pg.unsafe(text, values); + const rows = Array.from(result); + return { + rows, + rowCount: typeof result.count === 'number' ? result.count : rows.length, + }; +} + +sql.query = sqlQuery; + /** Close the pooled client (tests / long-running scripts). */ export async function closeSqlPool() { if (client) { diff --git a/lib/use-card-detail.js b/lib/use-card-detail.js index b500524..6b37d0c 100644 --- a/lib/use-card-detail.js +++ b/lib/use-card-detail.js @@ -26,6 +26,14 @@ export function useCardDetail({ user = null, authLoading = true } = {}) { const [decks, setDecks] = useState([]); const [cardCollections, setCardCollections] = useState([]); const [cardDecks, setCardDecks] = useState([]); + const [pricing, setPricing] = useState(null); + const [pricingLoading, setPricingLoading] = useState(false); + const [priceHistory, setPriceHistory] = useState([]); + const [priceHistoryLoading, setPriceHistoryLoading] = useState(false); + const [nearbyStores, setNearbyStores] = useState([]); + const [nearbyStoresLoading, setNearbyStoresLoading] = useState(false); + const [nearbyStoresError, setNearbyStoresError] = useState(null); + const [nearbyStoresNoHomeZip, setNearbyStoresNoHomeZip] = useState(false); // Mana symbol settings const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); @@ -82,6 +90,55 @@ export function useCardDetail({ user = null, authLoading = true } = {}) { fetchCard(); }, [id]); + + useEffect(() => { + if (!id || activeTab !== 'purchase') return; + let cancelled = false; + + const fetchPricing = async () => { + setPricingLoading(true); + try { + const response = await fetch(`/api/cards/${id}/pricing`); + if (response.ok && !cancelled) { + setPricing(await response.json()); + } + } catch (error) { + console.error('Error fetching card pricing:', error); + } finally { + if (!cancelled) setPricingLoading(false); + } + }; + + fetchPricing(); + return () => { + cancelled = true; + }; + }, [id, activeTab]); + + useEffect(() => { + if (!id || activeTab !== 'price-history') return; + let cancelled = false; + + const fetchHistory = async () => { + setPriceHistoryLoading(true); + try { + const response = await fetch(`/api/cards/${id}/price-history?days=90`); + if (response.ok && !cancelled) { + const data = await response.json(); + setPriceHistory(data.points || []); + } + } catch (error) { + console.error('Error fetching price history:', error); + } finally { + if (!cancelled) setPriceHistoryLoading(false); + } + }; + + fetchHistory(); + return () => { + cancelled = true; + }; + }, [id, activeTab]); // Fetch user's collections and decks useEffect(() => { @@ -129,6 +186,67 @@ export function useCardDetail({ user = null, authLoading = true } = {}) { fetchUserData(); } }, [card, id]); + + useEffect(() => { + if (activeTab !== 'purchase' || !user) { + return; + } + + let cancelled = false; + + const fetchNearbyStores = async () => { + setNearbyStoresLoading(true); + setNearbyStoresError(null); + setNearbyStoresNoHomeZip(false); + + try { + const token = localStorage.getItem('auth_token'); + if (!token) { + setNearbyStores([]); + return; + } + + const response = await fetch('/api/stores/nearby', { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (cancelled) return; + + if (response.status === 400) { + const data = await response.json(); + if (data.code === 'NO_HOME_ZIP') { + setNearbyStores([]); + setNearbyStoresNoHomeZip(true); + return; + } + } + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + setNearbyStores([]); + setNearbyStoresError(data.error || 'Could not load nearby stores'); + return; + } + + const data = await response.json(); + setNearbyStores(data.stores ?? []); + } catch (error) { + if (!cancelled) { + console.error('Error fetching nearby stores:', error); + setNearbyStoresError('Could not load nearby stores'); + } + } finally { + if (!cancelled) { + setNearbyStoresLoading(false); + } + } + }; + + fetchNearbyStores(); + return () => { + cancelled = true; + }; + }, [activeTab, user]); const getRarityGradient = (rarity) => { const rarityKey = rarity?.toLowerCase(); @@ -369,9 +487,17 @@ export function useCardDetail({ user = null, authLoading = true } = {}) { isFavorited, loading, manaSymbolSettings, + nearbyStores, + nearbyStoresError, + nearbyStoresLoading, + nearbyStoresNoHomeZip, ownedQuantity, particleStyles, getParticleCount, + priceHistory, + priceHistoryLoading, + pricing, + pricingLoading, quantity, router, selectedCollection, diff --git a/lib/use-collection-view.js b/lib/use-collection-view.js index f645489..08bc85f 100644 --- a/lib/use-collection-view.js +++ b/lib/use-collection-view.js @@ -34,7 +34,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) { const [selectedRarity, setSelectedRarity] = useState('All Rarities'); const [selectedType, setSelectedType] = useState('All Types'); const [groupBy, setGroupBy] = useState('Group by Game'); - const [sortBy, setSortBy] = useState('Sort by Name'); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState('grid'); const [searchCards, setSearchCards] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -118,11 +119,14 @@ export function useCollectionView({ user = null, authLoading = true } = {}) { tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : []) }); + const sortParams = new URLSearchParams({ sort: sortBy, order: sortOrder }); + const cardsUrl = `/api/collections/${identifier}/cards?${sortParams}`; + // Fetch collection cards (try public first, then authenticated) - let cardsResponse = await fetch(`/api/collections/${identifier}/cards`); + let cardsResponse = await fetch(cardsUrl); if (!cardsResponse.ok && user) { - cardsResponse = await fetch(`/api/collections/${identifier}/cards`, { + cardsResponse = await fetch(cardsUrl, { headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` } @@ -160,8 +164,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) { loadFavoritedCards(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes - }, [identifier, user]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug, auth, or sort changes + }, [identifier, user, sortBy, sortOrder]); const handleEditCollection = async () => { try { @@ -488,6 +492,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) { setShowShareModal, setShowUploadModal, setSortBy, + setSortOrder, setViewMode, showDeleteModal, showEditModal, @@ -496,6 +501,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) { showShareModal, showUploadModal, sortBy, + sortOrder, toggleFavorite, togglePublic, viewMode diff --git a/migrations/1783000000001_add-pricing-schema.js b/migrations/1783000000001_add-pricing-schema.js new file mode 100644 index 0000000..271195d --- /dev/null +++ b/migrations/1783000000001_add-pricing-schema.js @@ -0,0 +1,64 @@ +/** + * Pricing convoy schema: price timestamps, history snapshots, home zip, geo caches. + * + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +export const up = (pgm) => { + pgm.sql(` + ALTER TABLE cards + ADD COLUMN IF NOT EXISTS prices_updated_at TIMESTAMPTZ; + + CREATE TABLE IF NOT EXISTS card_price_history ( + id SERIAL PRIMARY KEY, + card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + source VARCHAR(32) NOT NULL, + price_type VARCHAR(32) NOT NULL DEFAULT 'market', + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + amount DECIMAL(10,2) NOT NULL, + recorded_at DATE NOT NULL DEFAULT CURRENT_DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (card_id, source, price_type, currency, recorded_at) + ); + + CREATE INDEX IF NOT EXISTS idx_card_price_history_card_date + ON card_price_history (card_id, recorded_at DESC); + + ALTER TABLE users + ADD COLUMN IF NOT EXISTS home_zip VARCHAR(10); + + CREATE TABLE IF NOT EXISTS geo_zip_cache ( + zip VARCHAR(10) PRIMARY KEY, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + display_name TEXT, + geocoded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS geo_stores_cache ( + cache_key VARCHAR(64) PRIMARY KEY, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + radius_meters INTEGER NOT NULL, + stores JSONB NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_geo_stores_cache_fetched + ON geo_stores_cache (fetched_at); + `); +}; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ +export const down = (pgm) => { + throw new Error( + '[migration:1783000000001_add-pricing-schema] Refusing to drop pricing schema. ' + + 'Rolling back would erase card_price_history snapshots and geo caches.' + ); +}; diff --git a/pages/api/cards/[id].js b/pages/api/cards/[id].js index ce81c7e..3f351a2 100644 --- a/pages/api/cards/[id].js +++ b/pages/api/cards/[id].js @@ -10,7 +10,7 @@ export default async function handler(req, res) { id, name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, card_type, colors, oracle_text, power, toughness, image_url, stock_image_url, - current_price, market_price, scryfall_id, verified, + current_price, market_price, prices_updated_at, scryfall_id, verified, quantity FROM cards WHERE id = ${id} diff --git a/pages/api/cards/[id]/price-history.js b/pages/api/cards/[id]/price-history.js new file mode 100644 index 0000000..dba3f72 --- /dev/null +++ b/pages/api/cards/[id]/price-history.js @@ -0,0 +1,32 @@ +import { sql } from '../../../../lib/sql.js'; +import { getPriceHistoryPoints } from '../../../../lib/pricing/price-history.js'; + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const cardId = parseInt(req.query.id, 10); + if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) { + return res.status(400).json({ error: 'Valid card ID is required' }); + } + + const daysRaw = req.query.days ?? '90'; + const days = parseInt(daysRaw, 10); + if (Number.isNaN(days) || days < 1 || days > 365) { + return res.status(400).json({ error: 'days must be between 1 and 365' }); + } + + const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`; + if (cardCheck.rows.length === 0) { + return res.status(404).json({ error: 'Card not found' }); + } + + const points = await getPriceHistoryPoints(cardId, { days }); + return res.status(200).json({ points }); + } catch (err) { + console.error('[GET /api/cards/[id]/price-history]', err); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/cards/[id]/pricing.js b/pages/api/cards/[id]/pricing.js new file mode 100644 index 0000000..fe00060 --- /dev/null +++ b/pages/api/cards/[id]/pricing.js @@ -0,0 +1,24 @@ +import { getCardPricing } from '../../../../lib/pricing/pricing-service.js'; + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const cardId = parseInt(req.query.id, 10); + if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) { + return res.status(400).json({ error: 'Valid card ID is required' }); + } + + const pricing = await getCardPricing(cardId); + if (!pricing) { + return res.status(404).json({ error: 'Card not found' }); + } + + return res.status(200).json(pricing); + } catch (err) { + console.error('[GET /api/cards/[id]/pricing]', err); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/cards/owned.js b/pages/api/cards/owned.js index 054c422..7c43e88 100644 --- a/pages/api/cards/owned.js +++ b/pages/api/cards/owned.js @@ -1,5 +1,27 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js'; + +const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']); +const VALID_ORDERS = new Set(['asc', 'desc']); + +function buildSortClause(sort, order) { + const dir = order === 'desc' ? 'DESC' : 'ASC'; + const nulls = 'NULLS LAST'; + const priceExpr = listUnitPriceExpr({ cardAlias: 'c' }); + + switch (sort) { + case 'price': + return `ORDER BY ${priceExpr} ${dir} ${nulls}, c.name ASC`; + case 'rarity': + return `ORDER BY c.rarity ${dir} ${nulls}, c.name ASC`; + case 'date': + return `ORDER BY uc.created_at ${dir} ${nulls}, c.name ASC`; + case 'name': + default: + return `ORDER BY c.name ${dir} ${nulls}`; + } +} export default async function handler(req, res) { if (req.method !== 'GET') { @@ -20,9 +42,15 @@ export default async function handler(req, res) { set = 'all', valueRange = 'all', page = '1', - limit = '50' + limit = '50', + sort: sortParam = 'name', + order: orderParam = 'asc', } = req.query; + const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name'; + const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc'; + const orderClause = buildSortClause(sort, order); + const pageNum = parseInt(page) || 1; const limitNum = parseInt(limit) || 50; const offset = (pageNum - 1) * limitNum; @@ -57,6 +85,7 @@ export default async function handler(req, res) { } if (valueRange !== 'all') { + const priceExpr = listUnitPriceExpr({ cardAlias: 'c' }); // Handle value range filtering const ranges = { 'under-1': [0, 1], @@ -69,11 +98,11 @@ export default async function handler(req, res) { if (ranges[valueRange]) { const [min, max] = ranges[valueRange]; if (valueRange === 'over-100') { - whereConditions.push(`c.market_price >= $${paramIndex}`); + whereConditions.push(`${priceExpr} >= $${paramIndex}`); params.push(min); paramIndex++; } else { - whereConditions.push(`c.market_price >= $${paramIndex} AND c.market_price <= $${paramIndex + 1}`); + whereConditions.push(`${priceExpr} >= $${paramIndex} AND ${priceExpr} <= $${paramIndex + 1}`); params.push(min, max); paramIndex += 2; } @@ -95,7 +124,7 @@ export default async function handler(req, res) { FROM cards c INNER JOIN user_cards uc ON c.id = uc.card_id WHERE ${whereClause} - ORDER BY c.name ASC + ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; diff --git a/pages/api/collections/[identifier].js b/pages/api/collections/[identifier].js index 05790d6..564bbe6 100644 --- a/pages/api/collections/[identifier].js +++ b/pages/api/collections/[identifier].js @@ -1,6 +1,10 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { isValidSlug } from '../../../lib/slug-utils'; +import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js'; + +const LIST_UNIT_PRICE_EXPR = listUnitPriceExpr({ cardAlias: 'cards' }); +const LIST_VALUED_QUANTITY = sql([`${LIST_UNIT_PRICE_EXPR} * cc.quantity`]); export default async function handler(req, res) { try { @@ -24,7 +28,7 @@ export default async function handler(req, res) { c.*, u.email as creator_email, COUNT(cc.card_id) as card_count, - COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, + COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value, ${user ? sql`cp.role as user_role, CASE WHEN c.user_id = ${user.userId} THEN 'owner' @@ -51,7 +55,7 @@ export default async function handler(req, res) { c.*, u.email as creator_email, COUNT(cc.card_id) as card_count, - COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, + COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value, ${user ? sql`cp.role as user_role, CASE WHEN c.user_id = ${user.userId} THEN 'owner' diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js index b3a4f55..dd6394b 100644 --- a/pages/api/collections/[identifier]/cards.js +++ b/pages/api/collections/[identifier]/cards.js @@ -1,6 +1,28 @@ import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; +import { listUnitPriceExpr } from '../../../../lib/pricing/collection-valuation.js'; + +const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']); +const VALID_ORDERS = new Set(['asc', 'desc']); + +function buildSortClause(sort, order) { + const dir = order === 'desc' ? 'DESC' : 'ASC'; + const nulls = 'NULLS LAST'; + const priceExpr = listUnitPriceExpr({ cardAlias: 'cards' }); + + switch (sort) { + case 'price': + return `ORDER BY ${priceExpr} ${dir} ${nulls}, cards.name ASC`; + case 'rarity': + return `ORDER BY cards.rarity ${dir} ${nulls}, cards.name ASC`; + case 'date': + return `ORDER BY cc.created_at ${dir} ${nulls}, cards.name ASC`; + case 'name': + default: + return `ORDER BY cards.name ${dir} ${nulls}`; + } +} export default async function handler(req, res) { try { @@ -50,17 +72,25 @@ export default async function handler(req, res) { const collection = collectionResult.rows[0]; if (req.method === 'GET') { - // Get all cards in the collection - const cardsResult = await sql` + const sortParam = req.query.sort ?? 'name'; + const orderParam = req.query.order ?? 'asc'; + const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name'; + const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc'; + const orderClause = buildSortClause(sort, order); + + const cardsResult = await sql.query( + ` SELECT cards.*, cc.quantity, cc.created_at as added_at FROM collection_cards cc JOIN cards ON cc.card_id = cards.id - WHERE cc.collection_id = ${collection.id} - ORDER BY cc.created_at DESC - `; + WHERE cc.collection_id = $1 + ${orderClause} + `, + [collection.id] + ); const cards = cardsResult.rows.map(card => ({ id: card.id, diff --git a/pages/api/stores/nearby.js b/pages/api/stores/nearby.js new file mode 100644 index 0000000..c8d83e5 --- /dev/null +++ b/pages/api/stores/nearby.js @@ -0,0 +1,73 @@ +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware.js'; +import { checkStoresRateLimit } from '../../../lib/rate-limit.js'; +import { getZipCoords, getStoresNear } from '../../../lib/pricing/geo-cache.js'; +import { clampRadiusMiles, milesToMeters } from '../../../lib/pricing/overpass.js'; + +function isLocalPricingEnabled() { + const flag = process.env.PRICING_LOCAL_ENABLED; + return flag === undefined || flag === '' || flag === 'true'; +} + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + if (!isLocalPricingEnabled()) { + return res.status(503).json({ error: 'Local store discovery is disabled' }); + } + + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const rateLimit = await checkStoresRateLimit(req, user.userId); + if (!rateLimit.allowed) { + const retryAfterSec = Math.max(1, Math.ceil((rateLimit.reset - Date.now()) / 1000)); + res.setHeader('Retry-After', String(retryAfterSec)); + return res.status(429).json({ error: 'Rate limit exceeded' }); + } + + const userRow = await sql` + SELECT home_zip + FROM users + WHERE id = ${user.userId} + LIMIT 1 + `; + + if (userRow.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const homeZip = userRow.rows[0].home_zip; + if (!homeZip) { + return res.status(400).json({ error: 'Home ZIP not configured', code: 'NO_HOME_ZIP' }); + } + + const radiusMiles = clampRadiusMiles( + req.query.radiusMiles != null ? Number(req.query.radiusMiles) : undefined + ); + const radiusMeters = milesToMeters(radiusMiles); + + const coords = await getZipCoords(homeZip); + if (!coords) { + return res.status(404).json({ error: 'Could not geocode home ZIP' }); + } + + const { stores, cached } = await getStoresNear(coords.lat, coords.lng, radiusMeters); + + return res.status(200).json({ + homeZip, + radiusMiles, + stores, + cached, + coordsCached: coords.cached, + }); + } catch (error) { + console.error('[stores/nearby]', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/user/settings.js b/pages/api/user/settings.js index df0509c..99b8386 100644 --- a/pages/api/user/settings.js +++ b/pages/api/user/settings.js @@ -16,7 +16,7 @@ export default async function handler(req, res) { id, email, role, first_name, last_name, username, collection_visibility, preferred_currency, cards_per_page, default_view, notifications_email, notifications_marketing, - two_factor_enabled, theme, language, + two_factor_enabled, theme, language, home_zip, created_at, updated_at FROM users WHERE id = ${user.userId} @@ -54,7 +54,10 @@ export default async function handler(req, res) { // Display Settings theme: userData.theme || 'system', - language: userData.language || 'en' + language: userData.language || 'en', + + // Local stores + home_zip: userData.home_zip ?? null, } }; @@ -71,7 +74,8 @@ export default async function handler(req, res) { notifications_email, notifications_marketing, theme, - language + language, + home_zip } = req.body; // Validate enum values @@ -101,6 +105,9 @@ export default async function handler(req, res) { if (language && !validLanguage.includes(language)) { return res.status(400).json({ error: 'Invalid language' }); } + if (home_zip !== undefined && home_zip !== null && !/^\d{5}$/.test(String(home_zip))) { + return res.status(400).json({ error: 'Invalid home ZIP — use a 5-digit US zip code' }); + } // Build update query dynamically const updateFields = []; @@ -152,6 +159,11 @@ export default async function handler(req, res) { updateValues.push(language); paramIndex++; } + if (home_zip !== undefined) { + updateFields.push(`home_zip = $${paramIndex}`); + updateValues.push(home_zip === '' ? null : home_zip); + paramIndex++; + } if (updateFields.length === 0) { return res.status(400).json({ error: 'No settings to update' }); @@ -170,7 +182,7 @@ export default async function handler(req, res) { id, email, role, first_name, last_name, username, collection_visibility, preferred_currency, cards_per_page, default_view, notifications_email, notifications_marketing, - two_factor_enabled, theme, language, + two_factor_enabled, theme, language, home_zip, created_at, updated_at `; @@ -201,7 +213,8 @@ export default async function handler(req, res) { notifications_email: userData.notifications_email, notifications_marketing: userData.notifications_marketing, theme: userData.theme, - language: userData.language + language: userData.language, + home_zip: userData.home_zip ?? null, } }; diff --git a/pages/api/user/stats.js b/pages/api/user/stats.js index b388e6d..eaa07d5 100644 --- a/pages/api/user/stats.js +++ b/pages/api/user/stats.js @@ -1,5 +1,10 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { effectiveUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js'; + +const UNIT_PRICE_EXPR = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' }); +const VALUED_QUANTITY = sql([`(${UNIT_PRICE_EXPR}) * user_cards.quantity`]); +const HAS_UNIT_PRICE = sql([`(${UNIT_PRICE_EXPR}) IS NOT NULL`]); export default async function handler(req, res) { if (req.method !== 'GET') { @@ -36,11 +41,11 @@ export default async function handler(req, res) { // Get total value of user's cards const valueResult = await sql` - SELECT COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value + SELECT COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value FROM user_cards JOIN cards ON user_cards.card_id = cards.id WHERE user_cards.user_id = ${user.userId} - AND cards.market_price IS NOT NULL + AND ${HAS_UNIT_PRICE} `; // Get game breakdown @@ -49,7 +54,7 @@ export default async function handler(req, res) { cards.game, COUNT(DISTINCT cards.id) as unique_cards, COALESCE(SUM(user_cards.quantity), 0) as total_quantity, - COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value + COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value FROM user_cards JOIN cards ON user_cards.card_id = cards.id WHERE user_cards.user_id = ${user.userId} @@ -63,7 +68,7 @@ export default async function handler(req, res) { cards.rarity, COUNT(DISTINCT cards.id) as unique_cards, COALESCE(SUM(user_cards.quantity), 0) as total_quantity, - COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value + COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value FROM user_cards JOIN cards ON user_cards.card_id = cards.id WHERE user_cards.user_id = ${user.userId} @@ -78,7 +83,7 @@ export default async function handler(req, res) { cards.game, cards.rarity, cards.image_url, - cards.market_price, + ${sql([`${UNIT_PRICE_EXPR} as market_price`])}, user_cards.quantity, user_cards.created_at FROM user_cards diff --git a/pages/my-cards.js b/pages/my-cards.js index 790c7b3..3815e77 100644 --- a/pages/my-cards.js +++ b/pages/my-cards.js @@ -23,6 +23,8 @@ export default function MyCards() { const [selectedRarity, setSelectedRarity] = useState('all'); const [selectedSet, setSelectedSet] = useState('all'); const [selectedValueRange, setSelectedValueRange] = useState('all'); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState('grid'); // grid or list const [pagination, setPagination] = useState({ page: 1, @@ -75,6 +77,8 @@ export default function MyCards() { const params = new URLSearchParams({ page: currentPage.toString(), limit: pagination.limit.toString(), + sort: sortBy, + order: sortOrder, ...(searchQuery && { search: searchQuery }), ...(selectedTCG !== 'all' && { tcg: selectedTCG }), ...(selectedRarity !== 'all' && { rarity: selectedRarity }), @@ -185,7 +189,7 @@ export default function MyCards() { loadFavoritedCards(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state - }, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]); + }, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange, sortBy, sortOrder]); // Infinite scroll useEffect(() => { @@ -382,6 +386,28 @@ export default function MyCards() { ))} + + + + diff --git a/pages/settings.js b/pages/settings.js index 65ee315..07ee112 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -23,7 +23,10 @@ export default function Settings() { // Display Settings theme: 'system', // light, dark, system - language: 'en' + language: 'en', + + // Local stores + home_zip: '', }); // Password change state @@ -531,6 +534,26 @@ export default function Settings() { ))} + + {/* Home ZIP for local stores */} +
+ + handleSettingChange('home_zip', e.target.value.replace(/\D/g, '').slice(0, 5))} + placeholder="e.g. 10001" + className="input-field w-full" + /> +

+ Used to find game and hobby stores near you on card detail. Leave blank to disable. +

+
)} diff --git a/test/api/cards-owned-sort.test.js b/test/api/cards-owned-sort.test.js new file mode 100644 index 0000000..d69cd0f --- /dev/null +++ b/test/api/cards-owned-sort.test.js @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { listUnitPriceExpr } from '../../lib/pricing/collection-valuation.js'; + +vi.mock('../../lib/sql.js', () => ({ sql: { query: vi.fn() } })); +vi.mock('../../lib/permission-middleware.js', () => ({ + getUserFromRequest: vi.fn(), +})); + +import { sql } from '../../lib/sql.js'; +import { getUserFromRequest } from '../../lib/permission-middleware.js'; +import handler from '../../pages/api/cards/owned.js'; + +const UNIT_PRICE_SQL = listUnitPriceExpr({ cardAlias: 'c' }); + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +describe('GET /api/cards/owned sort', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + sql.query.mockImplementation(async (queryText) => { + if (queryText.includes('COUNT(*)')) { + return { rows: [{ total: '0' }] }; + } + if (queryText.includes('DISTINCT')) { + return { rows: [] }; + } + return { rows: [] }; + }); + }); + + it('orders by effective price when sort=price&order=desc', async () => { + const req = { + method: 'GET', + query: { sort: 'price', order: 'desc' }, + }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + const cardsQueryCall = sql.query.mock.calls.find(([queryText]) => + queryText.includes('INNER JOIN user_cards') + ); + expect(cardsQueryCall).toBeDefined(); + const queryText = cardsQueryCall[0]; + expect(queryText).toContain(UNIT_PRICE_SQL); + expect(queryText).toContain('ORDER BY'); + expect(queryText).toContain('DESC'); + expect(queryText).toContain('NULLS LAST'); + }); + + it('defaults to name sort when sort param is invalid', async () => { + const req = { + method: 'GET', + query: { sort: 'invalid', order: 'asc' }, + }; + const res = createRes(); + + await handler(req, res); + + const cardsQueryCall = sql.query.mock.calls.find(([queryText]) => + queryText.includes('INNER JOIN user_cards') + ); + expect(cardsQueryCall[0]).toContain('ORDER BY c.name ASC'); + }); +}); diff --git a/test/api/cards-price-history.test.js b/test/api/cards-price-history.test.js new file mode 100644 index 0000000..179de12 --- /dev/null +++ b/test/api/cards-price-history.test.js @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); +vi.mock('../../lib/pricing/price-history.js', () => ({ + getPriceHistoryPoints: vi.fn(), +})); + +import { sql } from '../../lib/sql.js'; +import { getPriceHistoryPoints } from '../../lib/pricing/price-history.js'; +import handler from '../../pages/api/cards/[id]/price-history.js'; + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +describe('GET /api/cards/[id]/price-history', () => { + beforeEach(() => { + vi.clearAllMocks(); + sql.mockResolvedValue({ rows: [{ id: 42 }] }); + getPriceHistoryPoints.mockResolvedValue([ + { date: '2026-01-01', amount: 1.25, source: 'scryfall', priceType: 'market', currency: 'USD' }, + ]); + }); + + it('returns 400 for invalid card id', async () => { + const req = { method: 'GET', query: { id: 'nope' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(sql).not.toHaveBeenCalled(); + }); + + it('returns 400 when days is out of range', async () => { + const req = { method: 'GET', query: { id: '42', days: '999' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(getPriceHistoryPoints).not.toHaveBeenCalled(); + }); + + it('returns 404 when card does not exist', async () => { + sql.mockResolvedValue({ rows: [] }); + const req = { method: 'GET', query: { id: '42', days: '30' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + }); + + it('returns points for a valid request', async () => { + const req = { method: 'GET', query: { id: '42', days: '30' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(getPriceHistoryPoints).toHaveBeenCalledWith(42, { days: 30 }); + expect(res.body.points).toHaveLength(1); + expect(res.body.points[0].amount).toBe(1.25); + }); +}); diff --git a/test/api/cards-pricing.test.js b/test/api/cards-pricing.test.js new file mode 100644 index 0000000..d0b7d72 --- /dev/null +++ b/test/api/cards-pricing.test.js @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/pricing/pricing-service.js', () => ({ + getCardPricing: vi.fn(), +})); + +import { getCardPricing } from '../../lib/pricing/pricing-service.js'; +import handler from '../../pages/api/cards/[id]/pricing.js'; + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +describe('GET /api/cards/[id]/pricing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 400 for invalid card id', async () => { + const req = { method: 'GET', query: { id: 'abc' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(getCardPricing).not.toHaveBeenCalled(); + }); + + it('returns 404 when card is missing', async () => { + getCardPricing.mockResolvedValue(null); + const req = { method: 'GET', query: { id: '99' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(404); + }); + + it('returns pricing payload with staleness metadata', async () => { + getCardPricing.mockResolvedValue({ + prices: { current: 1.25, market: 2.5, currency: 'USD' }, + pricesUpdatedAt: '2026-01-01T00:00:00.000Z', + stale: true, + outbound: { + tcgplayer: 'https://tcgplayer.example', + ebay: 'https://ebay.example', + }, + }); + + const req = { method: 'GET', query: { id: '42' } }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.stale).toBe(true); + expect(res.body.prices.current).toBe(1.25); + expect(res.body.outbound.tcgplayer).toContain('tcgplayer'); + }); +}); diff --git a/test/api/stores-nearby.test.js b/test/api/stores-nearby.test.js new file mode 100644 index 0000000..5fc33fc --- /dev/null +++ b/test/api/stores-nearby.test.js @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/sql.js', () => ({ + sql: vi.fn(), +})); +vi.mock('../../lib/permission-middleware.js', () => ({ + getUserFromRequest: vi.fn(), +})); +vi.mock('../../lib/rate-limit.js', () => ({ + checkStoresRateLimit: vi.fn(), +})); +vi.mock('../../lib/pricing/geo-cache.js', () => ({ + getZipCoords: vi.fn(), + getStoresNear: vi.fn(), +})); + +import { sql } from '../../lib/sql.js'; +import { getUserFromRequest } from '../../lib/permission-middleware.js'; +import { checkStoresRateLimit } from '../../lib/rate-limit.js'; +import { getZipCoords, getStoresNear } from '../../lib/pricing/geo-cache.js'; +import handler from '../../pages/api/stores/nearby.js'; + +function createRes() { + const res = { + statusCode: 200, + headers: {}, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + setHeader(name, value) { + res.headers[name] = value; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +describe('GET /api/stores/nearby', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PRICING_LOCAL_ENABLED = 'true'; + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + checkStoresRateLimit.mockResolvedValue({ allowed: true, remaining: 0, reset: Date.now() + 3600000 }); + sql.mockResolvedValue({ rows: [{ home_zip: '10001' }] }); + getZipCoords.mockResolvedValue({ lat: 40.7, lng: -74.0, displayName: 'NYC', cached: true }); + getStoresNear.mockResolvedValue({ + stores: [{ name: 'Game Shop', distanceMiles: 1.2 }], + cached: true, + }); + }); + + it('returns 401 without authentication', async () => { + getUserFromRequest.mockResolvedValue(null); + const res = createRes(); + await handler({ method: 'GET', query: {} }, res); + expect(res.statusCode).toBe(401); + }); + + it('returns 503 when local pricing is disabled', async () => { + process.env.PRICING_LOCAL_ENABLED = 'false'; + const res = createRes(); + await handler({ method: 'GET', query: {} }, res); + expect(res.statusCode).toBe(503); + }); + + it('returns 429 with Retry-After when rate limited', async () => { + checkStoresRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + reset: Date.now() + 1800000, + }); + const res = createRes(); + await handler({ method: 'GET', query: {} }, res); + expect(res.statusCode).toBe(429); + expect(res.headers['Retry-After']).toBeTruthy(); + }); + + it('returns stores for authenticated user with saved zip', async () => { + const res = createRes(); + await handler({ method: 'GET', query: { zip: '90210' } }, res); + expect(res.statusCode).toBe(200); + expect(res.body.homeZip).toBe('10001'); + expect(res.body.stores).toHaveLength(1); + expect(getZipCoords).toHaveBeenCalledWith('10001'); + expect(getStoresNear).toHaveBeenCalled(); + }); + + it('returns 400 when home zip is not configured', async () => { + sql.mockResolvedValue({ rows: [{ home_zip: null }] }); + const res = createRes(); + await handler({ method: 'GET', query: {} }, res); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('NO_HOME_ZIP'); + }); +}); diff --git a/test/api/user-settings-home-zip.test.js b/test/api/user-settings-home-zip.test.js new file mode 100644 index 0000000..65e4dff --- /dev/null +++ b/test/api/user-settings-home-zip.test.js @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/sql.js', () => ({ + sql: vi.fn(), +})); +vi.mock('../../lib/permission-middleware.js', () => ({ + getUserFromRequest: vi.fn(), +})); + +import { sql } from '../../lib/sql.js'; +import { getUserFromRequest } from '../../lib/permission-middleware.js'; +import handler from '../../pages/api/user/settings.js'; + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +const baseUserRow = { + id: 1, + email: 'a@b.c', + role: 'user', + first_name: 'A', + last_name: 'B', + username: 'ab', + collection_visibility: 'private', + preferred_currency: 'USD', + cards_per_page: 50, + default_view: 'grid', + notifications_email: true, + notifications_marketing: false, + two_factor_enabled: false, + theme: 'system', + language: 'en', + home_zip: '10001', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +describe('/api/user/settings home_zip', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + }); + + it('GET includes home_zip in settings', async () => { + sql.mockResolvedValue({ rows: [baseUserRow] }); + const res = createRes(); + await handler({ method: 'GET' }, res); + expect(res.statusCode).toBe(200); + expect(res.body.settings.home_zip).toBe('10001'); + }); + + it('PUT persists valid 5-digit zip', async () => { + sql.query = vi.fn().mockResolvedValue({ + rows: [{ ...baseUserRow, home_zip: '90210' }], + }); + + const res = createRes(); + await handler({ method: 'PUT', body: { home_zip: '90210' } }, res); + expect(res.statusCode).toBe(200); + expect(res.body.settings.home_zip).toBe('90210'); + expect(sql.query).toHaveBeenCalled(); + const queryText = sql.query.mock.calls[0][0]; + expect(queryText).toContain('home_zip'); + }); + + it('PUT rejects invalid zip', async () => { + const res = createRes(); + await handler({ method: 'PUT', body: { home_zip: '1234' } }, res); + expect(res.statusCode).toBe(400); + expect(res.body.error).toMatch(/zip/i); + }); + + it('PUT allows null to clear zip', async () => { + sql.query = vi.fn().mockResolvedValue({ + rows: [{ ...baseUserRow, home_zip: null }], + }); + + const res = createRes(); + await handler({ method: 'PUT', body: { home_zip: null } }, res); + expect(res.statusCode).toBe(200); + expect(res.body.settings.home_zip).toBeNull(); + }); +}); diff --git a/test/api/user-stats-valuation.test.js b/test/api/user-stats-valuation.test.js new file mode 100644 index 0000000..16fa00a --- /dev/null +++ b/test/api/user-stats-valuation.test.js @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/sql.js', () => ({ + sql: vi.fn((strings, ...values) => { + if (values.length === 0) { + return { __sqlFragment: strings.join('') }; + } + return Promise.resolve({ rows: [] }); + }), +})); +vi.mock('../../lib/permission-middleware.js', () => ({ + getUserFromRequest: vi.fn(), +})); + +import { sql } from '../../lib/sql.js'; +import { getUserFromRequest } from '../../lib/permission-middleware.js'; +import { effectiveUnitPriceExpr } from '../../lib/pricing/collection-valuation.js'; +import handler from '../../pages/api/user/stats.js'; + +const UNIT_PRICE_SQL = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' }); + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + }; + return res; +} + +function queryText(call) { + const [strings, ...values] = call; + const fragments = values.map((value) => + value && typeof value === 'object' && value.__sqlFragment ? value.__sqlFragment : '?' + ); + return strings.reduce((text, part, index) => text + part + (fragments[index] ?? ''), ''); +} + +describe('GET /api/user/stats foil-aware valuation', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + + sql.mockImplementation((strings, ...values) => { + if (values.length === 0) { + return { __sqlFragment: strings.join('') }; + } + return Promise.resolve({ rows: [] }); + }); + }); + + it('uses effective unit price SQL for totals and breakdowns', async () => { + sql.mockImplementation((strings, ...values) => { + if (values.length === 0) { + return { __sqlFragment: strings.join('') }; + } + + const text = queryText([strings, ...values]); + + if (text.includes('SUM') && text.includes('total_value') && !text.includes('GROUP BY')) { + return Promise.resolve({ rows: [{ total_value: '6.50' }] }); + } + if (text.includes('GROUP BY cards.game')) { + return Promise.resolve({ + rows: [{ game: 'MTG', unique_cards: '2', total_quantity: '3', total_value: '6.50' }], + }); + } + if (text.includes('GROUP BY cards.rarity')) { + return Promise.resolve({ + rows: [{ rarity: 'rare', unique_cards: '2', total_quantity: '3', total_value: '6.50' }], + }); + } + if (text.includes('ORDER BY user_cards.created_at DESC')) { + return Promise.resolve({ + rows: [ + { + name: 'Lightning Bolt', + game: 'MTG', + rarity: 'common', + image_url: null, + market_price: '1.00', + quantity: 2, + created_at: '2026-01-01T00:00:00.000Z', + }, + { + name: 'Lightning Bolt', + game: 'MTG', + rarity: 'common', + image_url: null, + market_price: '4.50', + quantity: 1, + created_at: '2026-01-02T00:00:00.000Z', + }, + ], + }); + } + if (text.includes('total_cards')) { + return Promise.resolve({ rows: [{ total_cards: '3' }] }); + } + if (text.includes('total_collections')) { + return Promise.resolve({ rows: [{ total_collections: '1' }] }); + } + if (text.includes('total_decks')) { + return Promise.resolve({ rows: [{ total_decks: '0' }] }); + } + + return Promise.resolve({ rows: [] }); + }); + + const req = { method: 'GET' }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.totalValue).toBe(6.5); + expect(res.body.recentActivity[0].marketPrice).toBe(1); + expect(res.body.recentActivity[1].marketPrice).toBe(4.5); + + const valuationCalls = sql.mock.calls.filter((call) => queryText(call).includes('total_value')); + + for (const call of valuationCalls) { + const text = queryText(call); + expect(text).toContain(UNIT_PRICE_SQL); + expect(text).not.toContain('cards.market_price * user_cards.quantity'); + } + + const recentActivityCall = sql.mock.calls.find((call) => + queryText(call).includes('ORDER BY user_cards.created_at DESC') + ); + expect(queryText(recentActivityCall)).toContain(`${UNIT_PRICE_SQL} as market_price`); + }); + + it('returns 401 when unauthenticated', async () => { + getUserFromRequest.mockResolvedValue(null); + + const req = { method: 'GET' }; + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect(sql).not.toHaveBeenCalled(); + }); +}); diff --git a/test/components/CardDetailLocalStores.test.js b/test/components/CardDetailLocalStores.test.js new file mode 100644 index 0000000..cc58e72 --- /dev/null +++ b/test/components/CardDetailLocalStores.test.js @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import CardDetailLocalStores from '../../components/card-detail/CardDetailLocalStores.js'; + +describe('CardDetailLocalStores', () => { + afterEach(() => cleanup()); + + it('prompts to set zip when missing', () => { + render(); + expect(screen.getByText(/game stores near you/i)).toBeTruthy(); + expect(screen.getByRole('link', { name: /set home zip/i }).getAttribute('href')).toBe('/settings'); + }); + + it('renders populated store list', () => { + render( + + ); + + expect(screen.getByText('Game Haven')).toBeTruthy(); + expect(screen.getByText(/2\.1 mi away/i)).toBeTruthy(); + expect(screen.getByRole('link', { name: /visit website/i }).getAttribute('href')).toBe('https://example.com'); + }); + + it('shows gentle empty copy when no stores found', () => { + render(); + expect(screen.getByText(/no game or hobby stores found/i)).toBeTruthy(); + }); +}); diff --git a/test/components/CardDetailPurchaseTab.test.js b/test/components/CardDetailPurchaseTab.test.js new file mode 100644 index 0000000..103fc34 --- /dev/null +++ b/test/components/CardDetailPurchaseTab.test.js @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, test } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import CardDetailPurchaseTab, { stalenessBadgeText } from '../../components/card-detail/CardDetailPurchaseTab'; + +afterEach(() => cleanup()); + +const formatCurrency = (amount) => `$${Number(amount).toFixed(2)}`; + +describe('stalenessBadgeText', () => { + test('shows outdated copy when stale', () => { + expect(stalenessBadgeText({ stale: true, pricesUpdatedAt: '2026-01-01T00:00:00.000Z' })) + .toBe('Prices may be outdated'); + }); + + test('shows updated date when fresh', () => { + const text = stalenessBadgeText({ stale: false, pricesUpdatedAt: '2026-01-15T00:00:00.000Z' }); + expect(text).toContain('Updated'); + expect(text).toContain('2026'); + }); +}); + +describe('', () => { + test('renders staleness badge text from pricing payload', () => { + render( + + ); + + expect(screen.getByTestId('pricing-staleness-badge').textContent) + .toBe('Prices may be outdated'); + expect(screen.getByText('View on TCGPlayer')).toBeDefined(); + expect(screen.getByTestId('card-detail-local-stores-slot')).toBeDefined(); + }); +}); diff --git a/test/lib/card-import/bulk-sync.test.js b/test/lib/card-import/bulk-sync.test.js new file mode 100644 index 0000000..a44466b --- /dev/null +++ b/test/lib/card-import/bulk-sync.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { mapCard } from '../../../lib/card-import/bulk-sync.js'; + +describe('mapCard', () => { + it('maps Scryfall usd to current_price and usd_foil to market_price', () => { + const mapped = mapCard({ + id: 'abc-123', + oracle_id: 'oracle-1', + name: 'Lightning Bolt', + set_name: 'Alpha', + set: 'lea', + collector_number: '161', + rarity: 'common', + prices: { + usd: '1.50', + usd_foil: '12.00', + }, + }); + + expect(mapped.currentPrice).toBe(1.5); + expect(mapped.marketPrice).toBe(12); + expect(mapped.scryfallId).toBe('abc-123'); + }); + + it('leaves prices null when Scryfall omits price fields', () => { + const mapped = mapCard({ + id: 'no-price', + name: 'Unknown Card', + set_name: 'Test', + set: 'tst', + collector_number: '1', + rarity: 'common', + prices: {}, + }); + + expect(mapped.currentPrice).toBeNull(); + expect(mapped.marketPrice).toBeNull(); + }); + + it('parses only nonfoil price when foil is absent', () => { + const mapped = mapCard({ + id: 'nonfoil-only', + name: 'Island', + set_name: 'Test', + set: 'tst', + collector_number: '2', + rarity: 'common', + prices: { usd: '0.25' }, + }); + + expect(mapped.currentPrice).toBe(0.25); + expect(mapped.marketPrice).toBeNull(); + }); +}); diff --git a/test/lib/card-import/lorcana-bulk.test.js b/test/lib/card-import/lorcana-bulk.test.js new file mode 100644 index 0000000..cfab24f --- /dev/null +++ b/test/lib/card-import/lorcana-bulk.test.js @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../lib/pricing/price-history.js', () => ({ + recordPriceSnapshots: vi.fn(async (rows) => rows.length), +})); + +import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js'; +import { + enrichLorcanaRowWithFolio, + mapLorcanaCard, + runBulkLorcanaSync, +} from '../../../lib/card-import/lorcana-bulk.js'; +import { buildFolioPriceIndex } from '../../../lib/pricing/foolio-client.js'; + +describe('mapLorcanaCard pricing', () => { + it('includes price fields when Foilio pricing is provided', () => { + const mapped = mapLorcanaCard( + { + Unique_ID: 'TFC-001', + Name: 'Ariel - On Human Legs', + Set_Name: 'The First Chapter', + Set_ID: 'TFC', + Card_Num: 1, + }, + { currentPrice: 1.25, marketPrice: 1.5, currency: 'USD' } + ); + + expect(mapped.currentPrice).toBe(1.25); + expect(mapped.marketPrice).toBe(1.5); + expect(mapped.pricesUpdatedAt).toBeInstanceOf(Date); + }); +}); + +describe('enrichLorcanaRowWithFolio', () => { + it('matches by card id and tracks matched/unmatched counts', () => { + const index = buildFolioPriceIndex([ + { card_id: 'TFC-001', usd_low: 1, usd_market: 2 }, + ]); + const stats = { pricesMatched: 0, unmatchedCount: 0 }; + + const enriched = enrichLorcanaRowWithFolio( + mapLorcanaCard({ + Unique_ID: 'TFC-001', + Name: 'Ariel - On Human Legs', + Set_ID: 'TFC', + }), + index, + stats + ); + + expect(enriched.currentPrice).toBe(1); + expect(enriched.marketPrice).toBe(2); + expect(stats.pricesMatched).toBe(1); + expect(stats.unmatchedCount).toBe(0); + }); + + it('increments unmatchedCount when no feed row matches', () => { + const stats = { pricesMatched: 0, unmatchedCount: 0 }; + const enriched = enrichLorcanaRowWithFolio( + mapLorcanaCard({ + Unique_ID: 'TFC-999', + Name: 'Unknown', + Set_ID: 'TFC', + }), + buildFolioPriceIndex([]), + stats + ); + + expect(enriched.currentPrice).toBeNull(); + expect(stats.unmatchedCount).toBe(1); + }); +}); + +describe('runBulkLorcanaSync', () => { + beforeEach(() => { + vi.mocked(recordPriceSnapshots).mockClear(); + vi.restoreAllMocks(); + }); + + it('fetches Foilio feed once and reports price match summary', async () => { + const fetchFolioIndex = vi.fn(async () => buildFolioPriceIndex([ + { card_id: 'TFC-001', usd_low: 0.5, usd_market: 0.75 }, + ])); + + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ([ + { + Unique_ID: 'TFC-001', + Name: 'Ariel - On Human Legs', + Set_Name: 'The First Chapter', + Set_ID: 'TFC', + Card_Num: 1, + Gamemode: 'Lorcana', + }, + ]), + }))); + + const query = vi.fn(async (text, values) => { + if (String(text).includes('SELECT id, scryfall_id')) { + return { rows: [{ id: 1, scryfall_id: 'TFC-001' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const summary = await runBulkLorcanaSync({ + sql: { query }, + fetchFolioIndex, + }); + + expect(fetchFolioIndex).toHaveBeenCalledTimes(1); + expect(summary.pricesMatched).toBe(1); + expect(summary.unmatchedCount).toBe(0); + expect(summary.upserted).toBe(1); + expect(recordPriceSnapshots).toHaveBeenCalled(); + }); +}); diff --git a/test/lib/card-import/pokemon-bulk.test.js b/test/lib/card-import/pokemon-bulk.test.js new file mode 100644 index 0000000..4ea4620 --- /dev/null +++ b/test/lib/card-import/pokemon-bulk.test.js @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../lib/sql.js', () => ({ + sql: Object.assign(vi.fn(async () => ({ rows: [], rowCount: 0 })), { + query: vi.fn(async (text, values) => { + if (String(text).includes('SELECT id, scryfall_id')) { + const ids = values?.[0] ?? []; + return { + rows: ids.map((scryfallId, index) => ({ id: index + 1, scryfall_id: scryfallId })), + rowCount: ids.length, + }; + } + return { rows: [], rowCount: 0 }; + }), + }), +})); + +vi.mock('../../../lib/pricing/price-history.js', () => ({ + recordPriceSnapshots: vi.fn(async (rows) => rows.length), +})); + +vi.mock('../../../lib/card-import/pokemon-github.js', () => ({ + fetchPokemonSets: vi.fn(async () => [{ id: 'me3', name: 'Perfect Order', printedTotal: 88 }]), + fetchPokemonSetCards: vi.fn(async () => [ + { + id: 'me3-18', + name: 'Seel', + supertype: 'Pokémon', + number: '18', + rarity: 'Common', + types: ['Water'], + }, + ]), + mapGithubCardForInsert: vi.fn((card, setMeta) => ({ + externalId: card.id, + name: card.name, + setName: setMeta.name, + setCode: setMeta.id, + cardNumber: `${card.number}/${setMeta.printedTotal}`, + rarity: card.rarity, + cardType: card.supertype, + types: card.types, + flavorText: null, + hp: null, + imageSmall: null, + imageLarge: null, + })), +})); + +import { sql } from '../../../lib/sql.js'; +import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js'; +import { + mapPokemonBulkRow, + runBulkPokemonSync, +} from '../../../lib/card-import/pokemon-bulk.js'; + +describe('mapPokemonBulkRow', () => { + it('includes TCGdex prices and prices_updated_at when pricing is provided', () => { + const row = mapPokemonBulkRow( + { + externalId: 'me3-18', + name: 'Seel', + setName: 'Perfect Order', + setCode: 'me3', + cardNumber: '18/88', + rarity: 'Common', + cardType: 'Pokémon', + types: ['Water'], + flavorText: null, + hp: null, + imageSmall: null, + imageLarge: null, + }, + { unlimited: 'Legal' }, + { currentPrice: 0.1, marketPrice: 0.2, currency: 'USD' } + ); + + expect(row.currentPrice).toBe(0.1); + expect(row.marketPrice).toBe(0.2); + expect(row.pricesUpdatedAt).toBeInstanceOf(Date); + }); +}); + +describe('runBulkPokemonSync', () => { + beforeEach(() => { + vi.mocked(sql.query).mockClear(); + vi.mocked(recordPriceSnapshots).mockClear(); + }); + + it('enriches rows from TCGdex and reports match counts', async () => { + const fetchTcgdexSetCards = vi.fn(async () => [ + { + id: 'me3-18', + localId: '18', + name: 'Seel', + set: { cardCount: { official: 88 } }, + pricing: { + tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } }, + }, + }, + ]); + + const summary = await runBulkPokemonSync({ + fetchTcgdexSetCards, + query: sql.query, + setDelayMs: 0, + }); + + expect(summary.pricesMatched).toBe(1); + expect(summary.unmatchedCount).toBe(0); + expect(summary.snapshotsWritten).toBeGreaterThan(0); + expect(recordPriceSnapshots).toHaveBeenCalled(); + expect(sql.query).toHaveBeenCalled(); + }); +}); diff --git a/test/lib/pricing/card-price.test.js b/test/lib/pricing/card-price.test.js new file mode 100644 index 0000000..6af7062 --- /dev/null +++ b/test/lib/pricing/card-price.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { isPriceStale, resolveUnitPrice, PRICE_STALE_HOURS } from '../../../lib/pricing/card-price.js'; + +describe('card-price', () => { + it('uses current_price for nonfoil MTG rows', () => { + expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: false })).toBe(1.5); + }); + + it('uses market_price for foil MTG rows', () => { + expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: true })).toBe(10); + }); + + it('falls back when primary column is null', () => { + expect(resolveUnitPrice({ currentPrice: null, marketPrice: 3, isFoil: false })).toBe(3); + expect(resolveUnitPrice({ currentPrice: 2, marketPrice: null, isFoil: true })).toBe(2); + }); + + it('detects stale prices', () => { + const fresh = new Date(Date.now() - 60 * 60 * 1000); + const stale = new Date(Date.now() - (PRICE_STALE_HOURS + 1) * 60 * 60 * 1000); + expect(isPriceStale(fresh)).toBe(false); + expect(isPriceStale(stale)).toBe(true); + expect(isPriceStale(null)).toBe(true); + }); +}); diff --git a/test/lib/pricing/collection-valuation.test.js b/test/lib/pricing/collection-valuation.test.js new file mode 100644 index 0000000..0c170c8 --- /dev/null +++ b/test/lib/pricing/collection-valuation.test.js @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { effectiveUnitPriceExpr, listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js'; + +describe('collection-valuation', () => { + it('builds foil-aware unit price SQL', () => { + expect(effectiveUnitPriceExpr()).toContain('uc.is_foil'); + expect(effectiveUnitPriceExpr()).toContain('c.current_price'); + expect(effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' })).toContain( + 'user_cards.is_foil' + ); + }); + + it('builds list unit price SQL', () => { + expect(listUnitPriceExpr()).toBe('COALESCE(c.current_price, c.market_price)'); + }); +}); diff --git a/test/lib/pricing/foolio-client.test.js b/test/lib/pricing/foolio-client.test.js new file mode 100644 index 0000000..0e5c812 --- /dev/null +++ b/test/lib/pricing/foolio-client.test.js @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + buildFolioPriceIndex, + getFolioFeedUrl, + matchFolioPrice, + parseFolioPriceEntry, +} from '../../../lib/pricing/foolio-client.js'; + +describe('foolio-client', () => { + beforeEach(() => { + delete process.env.FOILIO_PRICES_FEED_URL; + vi.restoreAllMocks(); + }); + + it('uses default Foilio feed URL', () => { + expect(getFolioFeedUrl()).toBe('https://foolio.net/api/prices/feed?game=lorcana'); + }); + + it('prefers USD fields when parsing feed rows', () => { + expect(parseFolioPriceEntry({ usd_low: 1, usd_market: 2 })).toEqual({ + currentPrice: 1, + marketPrice: 2, + currency: 'USD', + }); + }); + + it('falls back to EUR with currency noted when USD is absent', () => { + expect(parseFolioPriceEntry({ eur_low: 0.5, eur_market: 0.8 })).toEqual({ + currentPrice: 0.5, + marketPrice: 0.8, + currency: 'EUR', + }); + }); + + it('indexes feed rows by card id and normalized name|set', () => { + const index = buildFolioPriceIndex([ + { + card_id: 'TFC-001', + name: 'Ariel - On Human Legs', + set: 'TFC', + usd_low: 1, + usd_market: 1.5, + }, + ]); + + expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(1.5); + expect( + matchFolioPrice(index, { + scryfallId: 'OTHER', + name: 'Ariel - On Human Legs', + setCode: 'TFC', + })?.currentPrice + ).toBe(1); + }); + + it('fetchFolioPriceFeed loads and indexes the feed once', async () => { + const payload = [{ card_id: 'TFC-001', usd_market: 3 }]; + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => payload, + }))); + + const { fetchFolioPriceIndex } = await import('../../../lib/pricing/foolio-client.js'); + const index = await fetchFolioPriceIndex(); + + expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(3); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/lib/pricing/geo-cache.test.js b/test/lib/pricing/geo-cache.test.js new file mode 100644 index 0000000..f37d581 --- /dev/null +++ b/test/lib/pricing/geo-cache.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + STORES_CACHE_TTL_MS, + buildStoresCacheKey, + isStoresCacheFresh, +} from '../../../lib/pricing/geo-cache.js'; + +describe('geo-cache', () => { + it('builds stable cache keys for the same coordinates', () => { + const a = buildStoresCacheKey(40.12345, -74.98765, 16093); + const b = buildStoresCacheKey(40.12345, -74.98765, 16093); + expect(a).toBe(b); + expect(a.length).toBeLessThanOrEqual(64); + }); + + it('treats fresh cache entries as valid within TTL', () => { + const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS + 1000); + expect(isStoresCacheFresh(fetchedAt)).toBe(true); + }); + + it('expires cache entries after TTL', () => { + const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS - 1); + expect(isStoresCacheFresh(fetchedAt)).toBe(false); + }); + + it('rejects invalid fetched_at values', () => { + expect(isStoresCacheFresh(null)).toBe(false); + expect(isStoresCacheFresh('not-a-date')).toBe(false); + }); +}); diff --git a/test/lib/pricing/nominatim.test.js b/test/lib/pricing/nominatim.test.js new file mode 100644 index 0000000..b228325 --- /dev/null +++ b/test/lib/pricing/nominatim.test.js @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { geocodeUsZip } from '../../../lib/pricing/nominatim.js'; + +describe('nominatim', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('geocodes a US zip via allowlisted host', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ lat: '40.7128', lon: '-74.0060', display_name: 'New York, NY' }], + }); + + const result = await geocodeUsZip('10001', { fetchImpl }); + expect(result).toEqual({ + lat: 40.7128, + lng: -74.006, + displayName: 'New York, NY', + }); + + const calledUrl = fetchImpl.mock.calls[0][0]; + expect(calledUrl).toContain('nominatim.openstreetmap.org'); + expect(calledUrl).toContain('postalcode=10001'); + }); + + it('returns null when no results', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [], + }); + + const result = await geocodeUsZip('00000', { fetchImpl }); + expect(result).toBeNull(); + }); + + it('enforces minimum 1s between requests', async () => { + vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') }); + vi.resetModules(); + const { geocodeUsZip: geocodeFresh } = await import('../../../lib/pricing/nominatim.js'); + + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ lat: '40.0', lon: '-74.0', display_name: 'Test' }], + }); + + await geocodeFresh('10001', { fetchImpl }); + const secondPromise = geocodeFresh('10002', { fetchImpl }); + + await vi.advanceTimersByTimeAsync(500); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(500); + await secondPromise; + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/lib/pricing/outbound-links.test.js b/test/lib/pricing/outbound-links.test.js new file mode 100644 index 0000000..877522c --- /dev/null +++ b/test/lib/pricing/outbound-links.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { buildEbayUrl, buildOutboundLinks, buildTcgplayerUrl } from '../../../lib/pricing/outbound-links.js'; + +describe('outbound-links', () => { + const card = { name: 'Lightning Bolt', game: 'MTG', set_name: 'Alpha' }; + + it('builds tcgplayer search url', () => { + expect(buildTcgplayerUrl(card)).toContain('tcgplayer.com'); + expect(buildTcgplayerUrl(card)).toContain(encodeURIComponent('Lightning Bolt')); + }); + + it('builds ebay search url', () => { + expect(buildEbayUrl(card)).toContain('ebay.com'); + expect(buildEbayUrl(card)).toContain(encodeURIComponent('Lightning Bolt Alpha MTG')); + }); + + it('returns both outbound links', () => { + const links = buildOutboundLinks(card); + expect(links.tcgplayer).toBeTruthy(); + expect(links.ebay).toBeTruthy(); + }); +}); diff --git a/test/lib/pricing/overpass.test.js b/test/lib/pricing/overpass.test.js new file mode 100644 index 0000000..43e122d --- /dev/null +++ b/test/lib/pricing/overpass.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildOverpassQuery, + clampRadiusMiles, + milesToMeters, + parseOverpassResponse, +} from '../../../lib/pricing/overpass.js'; + +describe('overpass', () => { + it('clamps radius miles between 1 and 50', () => { + expect(clampRadiusMiles(0)).toBe(1); + expect(clampRadiusMiles(100)).toBe(50); + expect(clampRadiusMiles(25)).toBe(25); + }); + + it('builds fixed-template query with numeric injection only', () => { + const query = buildOverpassQuery({ lat: 40.1, lng: -74.2, radiusMeters: 16093 }); + expect(query).toContain('around:16093,40.1,-74.2'); + expect(query).toContain('shop"="games"'); + expect(query).toContain('shop"="hobby"'); + expect(query).not.toContain('; DROP'); + }); + + it('parses elements with distance-ready coordinates', () => { + const stores = parseOverpassResponse( + { + elements: [ + { + type: 'node', + id: 42, + lat: 40.11, + lon: -74.21, + tags: { + name: 'Game Haven', + 'addr:street': 'Main St', + 'addr:city': 'Town', + phone: '555-0100', + website: 'https://example.com', + }, + }, + ], + }, + { originLat: 40.1, originLng: -74.2 } + ); + + expect(stores).toHaveLength(1); + expect(stores[0]).toMatchObject({ + name: 'Game Haven', + lat: 40.11, + lng: -74.21, + phone: '555-0100', + website: 'https://example.com', + osmUrl: 'https://www.openstreetmap.org/node/42', + }); + expect(stores[0].distanceMiles).toBeGreaterThan(0); + expect(stores[0].address).toContain('Main St'); + }); + + it('converts miles to meters with clamping', () => { + expect(milesToMeters(25)).toBe(Math.round(25 * 1609.344)); + expect(milesToMeters(0)).toBe(Math.round(1 * 1609.344)); + }); +}); diff --git a/test/lib/pricing/price-history.test.js b/test/lib/pricing/price-history.test.js new file mode 100644 index 0000000..8e3803d --- /dev/null +++ b/test/lib/pricing/price-history.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('../../../lib/sql.js', () => ({ + sql: vi.fn(async () => ({ rows: [], rowCount: 0 })), +})); + +import { sql } from '../../../lib/sql.js'; +import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js'; + +describe('price-history', () => { + beforeEach(() => { + vi.mocked(sql).mockClear(); + }); + + it('skips invalid rows', async () => { + const written = await recordPriceSnapshots([ + { cardId: 1, source: 'scryfall', amount: null }, + { cardId: null, source: 'scryfall', amount: 1 }, + ]); + expect(written).toBe(0); + expect(sql).not.toHaveBeenCalled(); + }); + + it('upserts valid snapshot rows', async () => { + const written = await recordPriceSnapshots([ + { cardId: 42, source: 'scryfall', amount: 1.25, priceType: 'market', currency: 'USD' }, + ]); + expect(written).toBe(1); + expect(sql).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/lib/pricing/tcgdex-client.test.js b/test/lib/pricing/tcgdex-client.test.js new file mode 100644 index 0000000..d21d88e --- /dev/null +++ b/test/lib/pricing/tcgdex-client.test.js @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + buildTcgdexPriceIndex, + getTcgdexBaseUrl, + matchTcgdexCard, + parseTcgdexPricing, +} from '../../../lib/pricing/tcgdex-client.js'; + +describe('tcgdex-client', () => { + beforeEach(() => { + delete process.env.TCGDEX_BASE_URL; + vi.restoreAllMocks(); + }); + + it('uses default TCGdex base URL', () => { + expect(getTcgdexBaseUrl()).toBe('https://api.tcgdex.net/v2/en'); + }); + + it('parses TCGPlayer USD pricing into current and market prices', () => { + const parsed = parseTcgdexPricing({ + pricing: { + tcgplayer: { + normal: { + lowPrice: 0.5, + marketPrice: 1.25, + }, + }, + }, + }); + + expect(parsed).toEqual({ + currentPrice: 0.5, + marketPrice: 1.25, + currency: 'USD', + }); + }); + + it('falls back to Cardmarket EUR for market_price when USD is missing', () => { + const parsed = parseTcgdexPricing({ + pricing: { + cardmarket: { + trend: 2.5, + unit: 'EUR', + }, + }, + }); + + expect(parsed.currentPrice).toBeNull(); + expect(parsed.marketPrice).toBe(2.5); + expect(parsed.currency).toBe('EUR'); + }); + + it('matches cards by set card number with leading zero normalization', () => { + const index = buildTcgdexPriceIndex([ + { + id: 'me3-18', + localId: '18', + name: 'Seel', + set: { cardCount: { official: 88 } }, + }, + ]); + + const match = matchTcgdexCard(index, { + name: 'Seel', + cardNumber: '018/88', + }); + + expect(match?.id).toBe('me3-18'); + }); + + it('falls back to unique name match within a set index', () => { + const index = buildTcgdexPriceIndex([ + { id: 'sv1-001', localId: '1', name: 'Sprigatito' }, + ]); + + const match = matchTcgdexCard(index, { + name: 'Sprigatito', + cardNumber: null, + }); + + expect(match?.id).toBe('sv1-001'); + }); + + it('fetchSetCards loads set summaries and full card details', async () => { + const fetchMock = vi.fn(async (url) => { + if (url.endsWith('/sets/me3')) { + return { + ok: true, + json: async () => ({ + cards: [{ id: 'me3-18', localId: '18', name: 'Seel' }], + }), + }; + } + if (url.endsWith('/cards/me3-18')) { + return { + ok: true, + json: async () => ({ + id: 'me3-18', + localId: '18', + name: 'Seel', + pricing: { + tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } }, + }, + }), + }; + } + throw new Error(`unexpected url ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { fetchSetCards } = await import('../../../lib/pricing/tcgdex-client.js'); + const cards = await fetchSetCards('me3'); + + expect(cards).toHaveLength(1); + expect(cards[0].pricing.tcgplayer.normal.marketPrice).toBe(0.2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +});