fix: currency suggestion uses region detection, seed adds market prices
- 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:
@@ -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<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",
|
||||
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() {
|
||||
</div>
|
||||
|
||||
{showSuggestion && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
|
||||
<span className="text-sm text-blue-700">
|
||||
Based on your location, we suggest{" "}
|
||||
<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 flex-1">
|
||||
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
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user