Files
GearBox/src/client/routes/settings.tsx
Jean-Luc Makiola 8d7a668da4
All checks were successful
CI / ci (push) Successful in 1m23s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 1m20s
fix: resolve lint errors from phase 32/33/34 execution
Auto-fixed formatting issues and removed unused imports introduced
by background execution agents across currency, i18n, and sharing code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:32:32 +02:00

438 lines
12 KiB
TypeScript

import { createFileRoute, Link } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useApiKeys,
useAuth,
useCreateApiKey,
useDeleteApiKey,
} from "../hooks/useAuth";
import { useCurrency } from "../hooks/useCurrency";
import { useExportItems, useImportItems } from "../hooks/useItems";
import { useLanguage } from "../hooks/useLanguage";
import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit";
import type { Currency, WeightUnit } from "../lib/formatters";
import i18n from "../lib/i18n";
const LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
];
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
const CURRENCIES: { value: Currency; label: string }[] = [
{ value: "USD", label: "$" },
{ value: "EUR", label: "\u20AC" },
{ value: "GBP", label: "\u00A3" },
{ value: "JPY", label: "\u00A5" },
{ value: "CAD", label: "CA$" },
{ value: "AUD", label: "A$" },
];
export const Route = createFileRoute("/settings")({
component: SettingsPage,
});
function ApiKeySection() {
const { t } = useTranslation("settings");
const { data: keys } = useApiKeys();
const createKey = useCreateApiKey();
const deleteKey = useDeleteApiKey();
const [name, setName] = useState("");
const [newKey, setNewKey] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const result = await createKey.mutateAsync({ name });
setNewKey(result.key);
setName("");
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">
{t("apiKeys.title")}
</h3>
<p className="text-xs text-gray-500">{t("apiKeys.description")}</p>
{newKey && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs font-medium text-amber-800 mb-1">
{t("apiKeys.copyWarning")}
</p>
<code className="text-xs text-amber-900 break-all select-all">
{newKey}
</code>
<button
type="button"
onClick={() => setNewKey(null)}
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
>
{t("common:actions.dismiss")}
</button>
</div>
)}
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder={t("apiKeys.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
required
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
/>
<button
type="submit"
disabled={createKey.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{t("common:actions.create")}
</button>
</form>
{keys && keys.length > 0 && (
<div className="space-y-2">
{keys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg"
>
<div>
<span className="text-sm text-gray-900">{key.name}</span>
<span className="text-xs text-gray-400 ml-2">
{key.keyPrefix}...
</span>
</div>
<button
type="button"
onClick={() => deleteKey.mutate(key.id)}
className="text-xs text-red-500 hover:text-red-700"
>
{t("common:actions.revoke")}
</button>
</div>
))}
</div>
)}
</div>
);
}
function ImportExportSection() {
const { t } = useTranslation("settings");
const exportItems = useExportItems();
const importItems = useImportItems();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importResult, setImportResult] = useState<{
imported: number;
createdCategories: string[];
errors: string[];
} | null>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setImportResult(null);
try {
const result = await importItems.mutateAsync(file);
setImportResult(result);
} catch (err) {
setImportResult({
imported: 0,
createdCategories: [],
errors: [(err as Error).message],
});
}
// Reset so the same file can be imported again if needed
if (fileInputRef.current) fileInputRef.current.value = "";
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">
{t("importExport.title")}
</h3>
<p className="text-xs text-gray-500">{t("importExport.description")}</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={exportItems}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
{t("importExport.export")}
</button>
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
{importItems.isPending
? t("importExport.importing")
: t("importExport.import")}
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
disabled={importItems.isPending}
onChange={handleFileChange}
/>
</label>
</div>
{importResult && (
<div
className={`rounded-lg p-3 text-xs space-y-1 ${
importResult.errors.length > 0 && importResult.imported === 0
? "bg-red-50 border border-red-200 text-red-700"
: "bg-green-50 border border-green-200 text-green-700"
}`}
>
{importResult.imported > 0 && (
<p className="font-medium">
{t("importExport.imported", { count: importResult.imported })}
</p>
)}
{importResult.createdCategories.length > 0 && (
<p>
{t("importExport.newCategories", {
categories: importResult.createdCategories.join(", "),
})}
</p>
)}
{importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
<p key={i} className="text-red-600">
{err}
</p>
))}
{importResult.imported === 0 && importResult.errors.length === 0 && (
<p>{t("importExport.noItemsFound")}</p>
)}
</div>
)}
</div>
);
}
const LOCALE_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";
// Fall back to language prefix
const prefix = lang.split("-")[0];
return LOCALE_CURRENCY_MAP[prefix] ?? null;
} catch {
return null;
}
}
function SettingsPage() {
const { t } = useTranslation("settings");
const unit = useWeightUnit();
const language = useLanguage();
const { currency, showConversions } = useCurrency();
const updateSetting = useUpdateSetting();
const { data: auth } = useAuth();
const [suggestionDismissed, setSuggestionDismissed] = useState(false);
const suggestedCurrency = getSuggestedCurrency();
const showSuggestion =
!suggestionDismissed && suggestedCurrency && suggestedCurrency !== currency;
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="mb-6">
<Link
to="/"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
&larr; {t("common:actions.back")}
</Link>
<h1 className="text-xl font-semibold text-gray-900">{t("title")}</h1>
</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{" "}
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
suggestedCurrency}{" "}
({suggestedCurrency})
</span>
<button
type="button"
onClick={() => {
updateSetting.mutate({
key: "currency",
value: suggestedCurrency,
});
setSuggestionDismissed(true);
}}
className="text-sm font-medium text-blue-700 hover:text-blue-800 underline ml-3"
>
Use{" "}
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
suggestedCurrency}
</button>
</div>
)}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
{t("language.title")}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{t("language.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
{LANGUAGES.map((lang) => (
<button
key={lang.value}
type="button"
onClick={() => {
updateSetting.mutate({ key: "language", value: lang.value });
i18n.changeLanguage(lang.value);
}}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
language === lang.value
? "bg-white text-gray-700 shadow-sm font-medium"
: "text-gray-400 hover:text-gray-600"
}`}
>
{lang.label}
</button>
))}
</div>
</div>
<div className="border-t border-gray-100" />
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
{t("weightUnit.title")}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{t("weightUnit.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
{UNITS.map((u) => (
<button
key={u}
type="button"
onClick={() =>
updateSetting.mutate({
key: "weightUnit",
value: u,
})
}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
unit === u
? "bg-white text-gray-700 shadow-sm font-medium"
: "text-gray-400 hover:text-gray-600"
}`}
>
{u}
</button>
))}
</div>
</div>
<div className="border-t border-gray-100" />
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
{t("currency.title")}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{t("currency.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
{CURRENCIES.map((c) => (
<button
key={c.value}
type="button"
onClick={() =>
updateSetting.mutate({
key: "currency",
value: c.value,
})
}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
currency === c.value
? "bg-white text-gray-700 shadow-sm font-medium"
: "text-gray-400 hover:text-gray-600"
}`}
>
{c.label}
</button>
))}
</div>
</div>
<div className="border-t border-gray-100" />
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
Show Converted Prices
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Display approximate conversions when local price is not available
</p>
</div>
<button
type="button"
onClick={() =>
updateSetting.mutate({
key: "showConversions",
value: showConversions ? "false" : "true",
})
}
className={`relative w-10 h-5 rounded-full transition-colors ${
showConversions ? "bg-blue-500" : "bg-gray-200"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
showConversions ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ImportExportSection />
</div>
{auth?.authenticated && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ApiKeySection />
</div>
)}
</div>
);
}