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>
438 lines
12 KiB
TypeScript
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"
|
|
>
|
|
← {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>
|
|
);
|
|
}
|