From 23027551b4d73a8dba332de088d276f557a69ae9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 21:27:57 +0200 Subject: [PATCH] fix: currency suggestion uses region detection, seed adds market prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Currency auto-suggestion now uses locale region subtag (en-US → US → USD, en-DE → DE → EUR) instead of language prefix. Fixes wrong suggestion for users with English browser locale in European countries. - Added dismiss button (X) to suggestion banner - Dev seed script now clears existing dev data before re-seeding (safe to run repeatedly without manual DB cleanup) - Added DEV_MARKET_PRICES with multi-market UVP data for 10 global items (EU/US/UK prices) and community prices for 5 owned items Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/routes/settings.tsx | 66 ++++++++--- src/db/dev-seed-data.ts | 206 +++++++++++++++++++++++++++++++++ src/db/dev-seed.ts | 114 +++++++++++++++--- 3 files changed, 356 insertions(+), 30 deletions(-) diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx index c009c72..22b9cf7 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -14,6 +14,7 @@ import { useUpdateSetting } from "../hooks/useSettings"; import { useWeightUnit } from "../hooks/useWeightUnit"; import type { Currency, WeightUnit } from "../lib/formatters"; import i18n from "../lib/i18n"; +import { LucideIcon } from "../lib/iconData"; const LANGUAGES = [ { value: "en", label: "English" }, @@ -215,27 +216,52 @@ function ImportExportSection() { ); } -const LOCALE_CURRENCY_MAP: Record = { +// Map region codes (from navigator.language) to currencies. +// Region is more reliable than language for currency detection: +// en-US → US → USD, en-GB → GB → GBP, en-DE → DE → EUR +const REGION_CURRENCY_MAP: Record = { + US: "USD", + GB: "GBP", + AU: "AUD", + CA: "CAD", + JP: "JPY", + // EU countries + DE: "EUR", + FR: "EUR", + ES: "EUR", + IT: "EUR", + NL: "EUR", + PT: "EUR", + AT: "EUR", + BE: "EUR", + IE: "EUR", + FI: "EUR", + GR: "EUR", +}; + +// Fallback: language prefix → currency (when no region subtag) +const LANG_CURRENCY_MAP: Record = { de: "EUR", fr: "EUR", es: "EUR", it: "EUR", nl: "EUR", - pt: "EUR", - en: "USD", ja: "JPY", }; function getSuggestedCurrency(): Currency | null { try { - const lang = navigator.language; - // Check full locale first (e.g., en-GB → GBP) - if (lang.startsWith("en-GB")) return "GBP"; - if (lang.startsWith("en-AU")) return "AUD"; - if (lang.startsWith("en-CA") || lang.startsWith("fr-CA")) return "CAD"; + const locale = navigator.language; // e.g., "en-US", "de-DE", "en" + const parts = locale.split("-"); + // Try region subtag first (more accurate for currency) + if (parts.length > 1) { + const region = parts[parts.length - 1].toUpperCase(); + const fromRegion = REGION_CURRENCY_MAP[region]; + if (fromRegion) return fromRegion; + } // Fall back to language prefix - const prefix = lang.split("-")[0]; - return LOCALE_CURRENCY_MAP[prefix] ?? null; + const lang = parts[0]; + return LANG_CURRENCY_MAP[lang] ?? null; } catch { return null; } @@ -267,9 +293,9 @@ function SettingsPage() { {showSuggestion && ( -
- - Based on your location, we suggest{" "} +
+ + Based on your region, we suggest{" "} {CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ?? suggestedCurrency}{" "} ({suggestedCurrency}) @@ -283,11 +309,17 @@ function SettingsPage() { }); setSuggestionDismissed(true); }} - className="text-sm font-medium text-blue-700 hover:text-blue-800 underline ml-3" + className="text-sm font-medium text-blue-700 hover:text-blue-800 underline shrink-0" > - Use{" "} - {CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ?? - suggestedCurrency} + Switch + +
)} diff --git a/src/db/dev-seed-data.ts b/src/db/dev-seed-data.ts index 6d77849..48f8df0 100644 --- a/src/db/dev-seed-data.ts +++ b/src/db/dev-seed-data.ts @@ -881,6 +881,212 @@ export const DEV_SETUPS = [ }, ] as const; +// ── Market Prices ───────────────────────────────────────────────── +// Multi-market UVP/MSRP prices for global items. +// globalItemIndex references DEV_GLOBAL_ITEMS array positions. + +export const DEV_MARKET_PRICES = [ + // Revelate Designs Terrapin (index 0) + { + globalItemIndex: 0, + market: "EU", + currency: "EUR", + priceCents: 18500, + source: "manufacturer", + }, + { + globalItemIndex: 0, + market: "US", + currency: "USD", + priceCents: 19900, + source: "manufacturer", + }, + { + globalItemIndex: 0, + market: "UK", + currency: "GBP", + priceCents: 16500, + source: "manufacturer", + }, + // Apidura Expedition Handlebar Pack (index 1) + { + globalItemIndex: 1, + market: "EU", + currency: "EUR", + priceCents: 16000, + source: "manufacturer", + }, + { + globalItemIndex: 1, + market: "US", + currency: "USD", + priceCents: 17500, + source: "manufacturer", + }, + { + globalItemIndex: 1, + market: "UK", + currency: "GBP", + priceCents: 14000, + source: "manufacturer", + }, + // Ortlieb Frame-Pack RC (index 2) + { + globalItemIndex: 2, + market: "EU", + currency: "EUR", + priceCents: 12000, + source: "manufacturer", + }, + { + globalItemIndex: 2, + market: "US", + currency: "USD", + priceCents: 14500, + source: "manufacturer", + }, + // Zpacks Duplex (index 6) — US brand, different pricing per market + { + globalItemIndex: 6, + market: "US", + currency: "USD", + priceCents: 67900, + source: "manufacturer", + }, + { + globalItemIndex: 6, + market: "EU", + currency: "EUR", + priceCents: 72000, + source: "retailer", + }, + { + globalItemIndex: 6, + market: "UK", + currency: "GBP", + priceCents: 62000, + source: "retailer", + }, + // Tarptent Stratospire Li (index 7) + { + globalItemIndex: 7, + market: "US", + currency: "USD", + priceCents: 62500, + source: "manufacturer", + }, + { + globalItemIndex: 7, + market: "EU", + currency: "EUR", + priceCents: 68000, + source: "retailer", + }, + // MSR Hubba Hubba (index 8) + { + globalItemIndex: 8, + market: "EU", + currency: "EUR", + priceCents: 49000, + source: "manufacturer", + }, + { + globalItemIndex: 8, + market: "US", + currency: "USD", + priceCents: 47000, + source: "manufacturer", + }, + { + globalItemIndex: 8, + market: "UK", + currency: "GBP", + priceCents: 42000, + source: "manufacturer", + }, + // Enlightened Equipment Enigma (index 10) + { + globalItemIndex: 10, + market: "US", + currency: "USD", + priceCents: 29500, + source: "manufacturer", + }, + { + globalItemIndex: 10, + market: "EU", + currency: "EUR", + priceCents: 33000, + source: "retailer", + }, + // Therm-a-Rest NeoAir XLite (index 13) + { + globalItemIndex: 13, + market: "EU", + currency: "EUR", + priceCents: 22000, + source: "manufacturer", + }, + { + globalItemIndex: 13, + market: "US", + currency: "USD", + priceCents: 21000, + source: "manufacturer", + }, + { + globalItemIndex: 13, + market: "UK", + currency: "GBP", + priceCents: 19000, + source: "manufacturer", + }, + // BRS-3000T Stove (index 15) — cheap item, big market variance + { + globalItemIndex: 15, + market: "EU", + currency: "EUR", + priceCents: 1500, + source: "retailer", + }, + { + globalItemIndex: 15, + market: "US", + currency: "USD", + priceCents: 2000, + source: "retailer", + }, + { + globalItemIndex: 15, + market: "UK", + currency: "GBP", + priceCents: 1300, + source: "retailer", + }, + // Wahoo ELEMNT BOLT (index 24) + { + globalItemIndex: 24, + market: "EU", + currency: "EUR", + priceCents: 27900, + source: "manufacturer", + }, + { + globalItemIndex: 24, + market: "US", + currency: "USD", + priceCents: 27999, + source: "manufacturer", + }, + { + globalItemIndex: 24, + market: "UK", + currency: "GBP", + priceCents: 24999, + source: "manufacturer", + }, +] as const; + // ── Settings ─────────────────────────────────────────────────────── export const DEV_SETTINGS = [ diff --git a/src/db/dev-seed.ts b/src/db/dev-seed.ts index 0cd0ef3..157de0b 100644 --- a/src/db/dev-seed.ts +++ b/src/db/dev-seed.ts @@ -1,11 +1,13 @@ // ── Dev Seed Runner ──────────────────────────────────────────────── -// Idempotent script to populate a dev database with realistic data. +// Clears dev data and re-seeds with fresh realistic data. // Usage: bun run db:seed:dev +// Preserves real (non-dev) users. Safe to run repeatedly. -import { and, eq } from "drizzle-orm"; +import { and, eq, like, sql } from "drizzle-orm"; import { DEV_CATEGORIES, DEV_GLOBAL_ITEMS, + DEV_MARKET_PRICES, DEV_SETTINGS, DEV_SETUPS, DEV_TAG_ASSIGNMENTS, @@ -18,19 +20,64 @@ import { seedGlobalItems } from "./seed-global-items.ts"; type Db = typeof db; -async function seedDevData(database: Db = db) { - // ── Idempotency check ────────────────────────────────────────── - const existing = await database - .select() - .from(schema.users) - .where(eq(schema.users.logtoSub, "dev-user-seed")) - .limit(1); +async function clearDevData(database: Db) { + console.log("Clearing existing dev seed data..."); - if (existing.length > 0) { - console.log("Dev seed data already exists, skipping."); - return; + // Find dev user(s) + const devUsers = await database + .select({ id: schema.users.id }) + .from(schema.users) + .where(like(schema.users.logtoSub, "dev-user%")); + + for (const user of devUsers) { + // Delete in FK order: setup_items → setups, thread_candidates → threads, items, categories, settings, shares + await database + .delete(schema.setupItems) + .where( + sql`${schema.setupItems.setupId} IN (SELECT id FROM setups WHERE user_id = ${user.id})`, + ); + await database + .delete(schema.shares) + .where( + sql`${schema.shares.setupId} IN (SELECT id FROM setups WHERE user_id = ${user.id})`, + ); + await database + .delete(schema.setups) + .where(eq(schema.setups.userId, user.id)); + await database + .delete(schema.threadCandidates) + .where( + sql`${schema.threadCandidates.threadId} IN (SELECT id FROM threads WHERE user_id = ${user.id})`, + ); + await database + .delete(schema.threads) + .where(eq(schema.threads.userId, user.id)); + await database + .delete(schema.communityPrices) + .where(eq(schema.communityPrices.userId, user.id)); + await database.delete(schema.items).where(eq(schema.items.userId, user.id)); + await database + .delete(schema.categories) + .where(eq(schema.categories.userId, user.id)); + await database + .delete(schema.settings) + .where(eq(schema.settings.userId, user.id)); + await database.delete(schema.users).where(eq(schema.users.id, user.id)); + console.log(` Cleared dev user id=${user.id}`); } + // Clear market prices (these are global, not user-scoped, but seeded by dev) + await database.delete(schema.marketPrices); + console.log(" Cleared market prices."); + + // Global items and tags are shared — leave them (seedGlobalItems handles idempotency) + console.log("Dev data cleared.\n"); +} + +async function seedDevData(database: Db = db) { + // ── Clear previous dev data ──────────────────────────────────── + await clearDevData(database); + try { // ── 1. Seed global items and tags ────────────────────────── await seedGlobalItems(database); @@ -286,9 +333,50 @@ async function seedDevData(database: Db = db) { } console.log(` ${DEV_SETTINGS.length} settings created.`); + // ── 12. Insert market prices ─────────────────────────────── + let marketPriceCount = 0; + for (const mp of DEV_MARKET_PRICES) { + const giId = globalItemIds[mp.globalItemIndex]; + if (!giId) continue; + await database.insert(schema.marketPrices).values({ + globalItemId: giId, + market: mp.market, + currency: mp.currency, + priceCents: mp.priceCents, + source: mp.source, + }); + marketPriceCount++; + } + console.log(` ${marketPriceCount} market prices created.`); + + // ── 13. Insert community prices ──────────────────────────── + // Seed a few community prices from the dev user for items they own + const ownedGlobalItemIds = insertedItems + .filter((i) => i.globalItemId !== null) + .map((i) => i.globalItemId as number); + + let communityPriceCount = 0; + for (const giId of ownedGlobalItemIds.slice(0, 5)) { + const item = insertedItems.find((i) => i.globalItemId === giId); + if (!item) continue; + await database.insert(schema.communityPrices).values({ + globalItemId: giId, + userId, + market: "EU", + currency: "EUR", + priceCents: item.priceCents + ? Math.round(item.priceCents * 0.85) + : 10000, + priceDate: new Date("2026-03-15"), + sourceType: "purchased", + }); + communityPriceCount++; + } + console.log(` ${communityPriceCount} community prices created.`); + // ── Summary ──────────────────────────────────────────────── console.log( - `\nDev seed complete: ${globalItemIds.length} global items, ${allTags.length} tags, ${insertedItems.length} user items, ${threadResults.length} threads, ${setupResults.length} setups`, + `\nDev seed complete: ${globalItemIds.length} global items, ${allTags.length} tags, ${insertedItems.length} user items, ${threadResults.length} threads, ${setupResults.length} setups, ${marketPriceCount} market prices`, ); } catch (err) { console.error("Seed failed:", err);