fix: currency suggestion uses region detection, seed adds market prices
All checks were successful
CI / ci (push) Successful in 1m24s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:27:57 +02:00
parent 51c8703a3d
commit 23027551b4
3 changed files with 356 additions and 30 deletions

View File

@@ -14,6 +14,7 @@ import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit"; import { useWeightUnit } from "../hooks/useWeightUnit";
import type { Currency, WeightUnit } from "../lib/formatters"; import type { Currency, WeightUnit } from "../lib/formatters";
import i18n from "../lib/i18n"; import i18n from "../lib/i18n";
import { LucideIcon } from "../lib/iconData";
const LANGUAGES = [ const LANGUAGES = [
{ value: "en", label: "English" }, { value: "en", label: "English" },
@@ -215,27 +216,52 @@ function ImportExportSection() {
); );
} }
const LOCALE_CURRENCY_MAP: Record<string, Currency> = { // 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<string, Currency> = {
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<string, Currency> = {
de: "EUR", de: "EUR",
fr: "EUR", fr: "EUR",
es: "EUR", es: "EUR",
it: "EUR", it: "EUR",
nl: "EUR", nl: "EUR",
pt: "EUR",
en: "USD",
ja: "JPY", ja: "JPY",
}; };
function getSuggestedCurrency(): Currency | null { function getSuggestedCurrency(): Currency | null {
try { try {
const lang = navigator.language; const locale = navigator.language; // e.g., "en-US", "de-DE", "en"
// Check full locale first (e.g., en-GB → GBP) const parts = locale.split("-");
if (lang.startsWith("en-GB")) return "GBP"; // Try region subtag first (more accurate for currency)
if (lang.startsWith("en-AU")) return "AUD"; if (parts.length > 1) {
if (lang.startsWith("en-CA") || lang.startsWith("fr-CA")) return "CAD"; const region = parts[parts.length - 1].toUpperCase();
const fromRegion = REGION_CURRENCY_MAP[region];
if (fromRegion) return fromRegion;
}
// Fall back to language prefix // Fall back to language prefix
const prefix = lang.split("-")[0]; const lang = parts[0];
return LOCALE_CURRENCY_MAP[prefix] ?? null; return LANG_CURRENCY_MAP[lang] ?? null;
} catch { } catch {
return null; return null;
} }
@@ -267,9 +293,9 @@ function SettingsPage() {
</div> </div>
{showSuggestion && ( {showSuggestion && (
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center justify-between"> <div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center gap-3">
<span className="text-sm text-blue-700"> <span className="text-sm text-blue-700 flex-1">
Based on your location, we suggest{" "} Based on your region, we suggest{" "}
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ?? {CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
suggestedCurrency}{" "} suggestedCurrency}{" "}
({suggestedCurrency}) ({suggestedCurrency})
@@ -283,11 +309,17 @@ function SettingsPage() {
}); });
setSuggestionDismissed(true); 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{" "} Switch
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ?? </button>
suggestedCurrency} <button
type="button"
onClick={() => setSuggestionDismissed(true)}
className="p-1 text-blue-400 hover:text-blue-600 rounded shrink-0"
aria-label="Dismiss"
>
<LucideIcon name="x" size={16} />
</button> </button>
</div> </div>
)} )}

View File

@@ -881,6 +881,212 @@ export const DEV_SETUPS = [
}, },
] as const; ] 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 ─────────────────────────────────────────────────────── // ── Settings ───────────────────────────────────────────────────────
export const DEV_SETTINGS = [ export const DEV_SETTINGS = [

View File

@@ -1,11 +1,13 @@
// ── Dev Seed Runner ──────────────────────────────────────────────── // ── 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 // 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 { import {
DEV_CATEGORIES, DEV_CATEGORIES,
DEV_GLOBAL_ITEMS, DEV_GLOBAL_ITEMS,
DEV_MARKET_PRICES,
DEV_SETTINGS, DEV_SETTINGS,
DEV_SETUPS, DEV_SETUPS,
DEV_TAG_ASSIGNMENTS, DEV_TAG_ASSIGNMENTS,
@@ -18,19 +20,64 @@ import { seedGlobalItems } from "./seed-global-items.ts";
type Db = typeof db; type Db = typeof db;
async function seedDevData(database: Db = db) { async function clearDevData(database: Db) {
// ── Idempotency check ────────────────────────────────────────── console.log("Clearing existing dev seed data...");
const existing = await database
.select()
.from(schema.users)
.where(eq(schema.users.logtoSub, "dev-user-seed"))
.limit(1);
if (existing.length > 0) { // Find dev user(s)
console.log("Dev seed data already exists, skipping."); const devUsers = await database
return; .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 { try {
// ── 1. Seed global items and tags ────────────────────────── // ── 1. Seed global items and tags ──────────────────────────
await seedGlobalItems(database); await seedGlobalItems(database);
@@ -286,9 +333,50 @@ async function seedDevData(database: Db = db) {
} }
console.log(` ${DEV_SETTINGS.length} settings created.`); 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 ──────────────────────────────────────────────── // ── Summary ────────────────────────────────────────────────
console.log( 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) { } catch (err) {
console.error("Seed failed:", err); console.error("Seed failed:", err);