feat(34-02): extract hardcoded strings from modals, routes, and catalog

- AddToCollectionModal: all labels, placeholders, toast messages
- collection/index.tsx: tab labels (Gear/Planning)
- threads/$threadId/index.tsx: thread detail page and AddCandidateModal
- items/$itemId.tsx: back links, action buttons, field labels, metadata
- setups/$setupId.tsx: all setup detail strings and confirm dialog
- users/$userId.tsx: public profile page strings
- global-items/index.tsx: discover/catalog filter UI strings
- Added catalog.json namespace (en + de) and registered in i18n.ts
- Extended en/de threads, setups, collection, common locales with missing keys
This commit is contained in:
2026-04-18 14:01:09 +02:00
parent 6fd8874970
commit 2aa156a6b7
18 changed files with 556 additions and 127 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCategories } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import { useCurrency } from "../hooks/useCurrency"; import { useCurrency } from "../hooks/useCurrency";
@@ -7,6 +8,7 @@ import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker"; import { CategoryPicker } from "./CategoryPicker";
export function AddToCollectionModal() { export function AddToCollectionModal() {
const { t } = useTranslation(["collection", "common"]);
const { open, globalItemId, globalItemName } = useUIStore( const { open, globalItemId, globalItemName } = useUIStore(
(s) => s.addToCollectionModal, (s) => s.addToCollectionModal,
); );
@@ -47,7 +49,7 @@ export function AddToCollectionModal() {
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (categoryId === null) { if (categoryId === null) {
setError("Please select a category"); setError(t("collection:addToCollection.selectCategory"));
return; return;
} }
setError(null); setError(null);
@@ -66,11 +68,11 @@ export function AddToCollectionModal() {
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast.success("Added to Collection"); toast.success(t("collection:addToCollection.added"));
closeAddToCollection(); closeAddToCollection();
}, },
onError: (err) => { onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to add item"); setError(err instanceof Error ? err.message : t("collection:addToCollection.failedToAdd"));
}, },
}, },
); );
@@ -92,14 +94,14 @@ export function AddToCollectionModal() {
onKeyDown={() => {}} onKeyDown={() => {}}
> >
<h2 className="text-lg font-semibold text-gray-900 mb-1"> <h2 className="text-lg font-semibold text-gray-900 mb-1">
Add to Collection {t("collection:addToCollection.title")}
</h2> </h2>
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p> <p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Category {t("collection:addToCollection.categoryLabel")}
</label> </label>
<CategoryPicker <CategoryPicker
value={categoryId ?? 0} value={categoryId ?? 0}
@@ -112,13 +114,13 @@ export function AddToCollectionModal() {
htmlFor="collection-notes" htmlFor="collection-notes"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Notes {t("collection:addToCollection.notesLabel")}
</label> </label>
<textarea <textarea
id="collection-notes" id="collection-notes"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Personal notes (optional)" placeholder={t("collection:addToCollection.notesPlaceholder")}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
/> />
@@ -129,14 +131,14 @@ export function AddToCollectionModal() {
htmlFor="collection-price" htmlFor="collection-price"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Purchase Price ({currency}) {t("collection:addToCollection.purchasePriceLabel", { currency })}
</label> </label>
<input <input
id="collection-price" id="collection-price"
type="number" type="number"
value={purchasePrice} value={purchasePrice}
onChange={(e) => setPurchasePrice(e.target.value)} onChange={(e) => setPurchasePrice(e.target.value)}
placeholder="Purchase price (optional)" placeholder={t("collection:addToCollection.purchasePricePlaceholder")}
min="0" min="0"
step="0.01" step="0.01"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
@@ -151,14 +153,14 @@ export function AddToCollectionModal() {
onClick={handleClose} onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel {t("common:actions.cancel")}
</button> </button>
<button <button
type="submit" type="submit"
disabled={createItem.isPending} disabled={createItem.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" 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"
> >
{createItem.isPending ? "Adding..." : "Add to Collection"} {createItem.isPending ? t("collection:addToCollection.addingButton") : t("collection:addToCollection.addButton")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,12 +1,14 @@
import i18n from "i18next"; import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import deCatalog from "../locales/de/catalog.json";
import deCollection from "../locales/de/collection.json"; import deCollection from "../locales/de/collection.json";
import deCommon from "../locales/de/common.json"; import deCommon from "../locales/de/common.json";
import deOnboarding from "../locales/de/onboarding.json"; import deOnboarding from "../locales/de/onboarding.json";
import deSettings from "../locales/de/settings.json"; import deSettings from "../locales/de/settings.json";
import deSetups from "../locales/de/setups.json"; import deSetups from "../locales/de/setups.json";
import deThreads from "../locales/de/threads.json"; import deThreads from "../locales/de/threads.json";
import enCatalog from "../locales/en/catalog.json";
import enCollection from "../locales/en/collection.json"; import enCollection from "../locales/en/collection.json";
import enCommon from "../locales/en/common.json"; import enCommon from "../locales/en/common.json";
import enOnboarding from "../locales/en/onboarding.json"; import enOnboarding from "../locales/en/onboarding.json";
@@ -26,6 +28,7 @@ i18n
setups: enSetups, setups: enSetups,
onboarding: enOnboarding, onboarding: enOnboarding,
settings: enSettings, settings: enSettings,
catalog: enCatalog,
}, },
de: { de: {
common: deCommon, common: deCommon,
@@ -34,6 +37,7 @@ i18n
setups: deSetups, setups: deSetups,
onboarding: deOnboarding, onboarding: deOnboarding,
settings: deSettings, settings: deSettings,
catalog: deCatalog,
}, },
}, },
supportedLngs: ["en", "de"], supportedLngs: ["en", "de"],

View File

@@ -0,0 +1,21 @@
{
"discover": "Entdecken",
"searchPlaceholder": "Katalog durchsuchen...",
"filter": {
"tags": "Tags",
"weight": "Gewicht",
"price": "Preis",
"weightRange": "Gewichtsbereich",
"priceRange": "Preisbereich",
"min": "Min: {{value}}",
"max": "Max: {{value}}",
"reset": "Zurücksetzen",
"clearAll": "Alle löschen",
"listView": "Listenansicht",
"gridView": "Gitteransicht"
},
"empty": {
"noResults": "Keine Artikel gefunden",
"noCatalogItems": "Noch keine Artikel im globalen Katalog"
}
}

View File

@@ -27,5 +27,118 @@
"light": "Leicht", "light": "Leicht",
"medium": "Mittel", "medium": "Mittel",
"heavy": "Schwer" "heavy": "Schwer"
},
"tabs": {
"setups": "Setups"
},
"totals": {
"totalWeight": "Gesamtgewicht",
"totalCost": "Gesamtkosten"
},
"classificationBadge": {
"base": "Basisgewicht",
"worn": "Getragen",
"consumable": "Verbrauchsmaterial"
},
"categoryPicker": {
"searchOrCreate": "Kategorie suchen oder erstellen...",
"create": "Erstellen",
"noCategories": "Keine Kategorien gefunden"
},
"categoryFilter": {
"allCategories": "Alle Kategorien",
"searchPlaceholder": "Kategorien suchen...",
"noResults": "Keine Kategorien gefunden"
},
"weightSummary": {
"title": "Gewichtsübersicht",
"noData": "Keine Gewichtsdaten verfügbar",
"baseWeight": "Basisgewicht",
"worn": "Getragen",
"consumable": "Verbrauchsmaterial",
"total": "Gesamt",
"category": "Kategorie",
"classification": "Klassifikation"
},
"itemPicker": {
"title": "Gegenstände auswählen",
"noItems": "Noch keine Gegenstände in Ihrer Sammlung.",
"done": "Fertig"
},
"categoryHeader": {
"itemCount": "{{count}} Gegenstände",
"itemCount_one": "{{count}} Gegenstand",
"save": "Speichern",
"cancel": "Abbrechen"
},
"linkToGlobal": {
"linkToCatalog": "Mit Katalog verknüpfen",
"linkToGlobalCatalog": "Mit globalem Katalog verknüpfen",
"searching": "Suchen...",
"noItemsFound": "Keine Gegenstände gefunden",
"unlink": "Verknüpfung aufheben",
"searchPlaceholder": "Nach Marke oder Modell suchen..."
},
"manualEntry": {
"namePlaceholder": "Gegenstandsname",
"weightLabel": "Gewicht (g)",
"msrpLabel": "UVP ($)",
"notesLabel": "Notizen",
"optionalNotes": "Optionale Notizen...",
"productLink": "Produktlink",
"addToCollection": "Zur Sammlung hinzufügen",
"nameRequired": "Name ist erforderlich",
"selectCategory": "Bitte wählen Sie eine Kategorie",
"failedToSave": "Speichern fehlgeschlagen"
},
"itemCard": {
"duplicateItem": "Gegenstand duplizieren",
"openProductLink": "Produktlink öffnen",
"removeFromSetup": "Aus Setup entfernen"
},
"addToCollection": {
"title": "Zur Sammlung hinzufügen",
"categoryLabel": "Kategorie",
"notesLabel": "Notizen",
"notesPlaceholder": "Persönliche Notizen (optional)",
"purchasePriceLabel": "Kaufpreis ({{currency}})",
"purchasePricePlaceholder": "Kaufpreis (optional)",
"selectCategory": "Bitte wählen Sie eine Kategorie",
"addButton": "Zur Sammlung hinzufügen",
"addingButton": "Hinzufügen...",
"added": "Zur Sammlung hinzugefügt",
"failedToAdd": "Gegenstand konnte nicht hinzugefügt werden"
},
"item": {
"backToSetup": "Zurück zum Setup",
"backToCollection": "Zurück zur Sammlung",
"notFound": "Gegenstand nicht gefunden",
"nameFromCatalog": "Name und Marke stammen aus dem Katalog",
"removeFromCollection": "Aus der Sammlung entfernen",
"weightLabel": "Gewicht (g)",
"msrpLabel": "UVP",
"priceLabel": "Preis ({{currency}})",
"quantityLabel": "Menge",
"categoryLabel": "Kategorie",
"notesLabel": "Notizen",
"notesPlaceholder": "Notizen hinzufügen...",
"productUrlLabel": "Produkt-URL",
"urlPlaceholder": "https://...",
"viewProduct": "Produkt ansehen",
"qty": "Menge: {{count}}",
"added": "Hinzugefügt",
"updated": "Aktualisiert"
},
"profileSection": {
"title": "Profil",
"subtitle": "Ihre öffentlichen Profilinformationen",
"changeAvatar": "Avatar ändern",
"removeAvatar": "Entfernen",
"uploadingAvatar": "Hochladen...",
"displayName": "Anzeigename",
"bio": "Biografie",
"saveProfile": "Profil speichern",
"profileUpdated": "Profil aktualisiert",
"avatarUploadFailed": "Avatar-Upload fehlgeschlagen."
} }
} }

View File

@@ -27,6 +27,7 @@
"loading": "Laden...", "loading": "Laden...",
"addItem": "Gegenstand hinzufügen", "addItem": "Gegenstand hinzufügen",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"duplicate": "Duplizieren",
"revoke": "Widerrufen", "revoke": "Widerrufen",
"skipStep": "Diesen Schritt überspringen" "skipStep": "Diesen Schritt überspringen"
}, },
@@ -76,5 +77,45 @@
"showing": "{{filtered}} von {{total}} Gegenständen", "showing": "{{filtered}} von {{total}} Gegenständen",
"searchItems": "Gegenstände suchen...", "searchItems": "Gegenstände suchen...",
"allCategories": "Alle Kategorien" "allCategories": "Alle Kategorien"
},
"home": {
"popularSetups": "Beliebte Setups",
"recentlyAdded": "Zuletzt hinzugefügt",
"trendingCategories": "Trending-Kategorien"
},
"imageUpload": {
"clickToAdd": "Klicken zum Hinzufügen eines Fotos",
"invalidType": "Bitte wählen Sie ein JPG-, PNG- oder WebP-Bild.",
"tooLarge": "Das Bild muss kleiner als 5 MB sein.",
"uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen."
},
"profile": {
"title": "Profil",
"account": "Konto",
"accountInfo": "Ihre Kontoinformationen",
"email": "E-Mail",
"noEmail": "Keine E-Mail-Adresse hinterlegt",
"change": "Ändern",
"newEmailPlaceholder": "Neue E-Mail-Adresse",
"updating": "Wird aktualisiert...",
"updateEmail": "E-Mail aktualisieren",
"emailUpdated": "E-Mail aktualisiert",
"memberSince": "Mitglied seit",
"security": "Sicherheit",
"managePassword": "Ihr Passwort verwalten",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"passwordRequirements": "Das Passwort muss mindestens 8 Zeichen mit Groß-, Kleinbuchstaben und einer Zahl enthalten.",
"passwordUpdated": "Passwort aktualisiert",
"changingPassword": "Wird geändert...",
"changePassword": "Passwort ändern",
"setPassword": "Passwort festlegen",
"dangerZone": "Gefahrenzone",
"dangerZoneDescription": "Konto und alle persönlichen Daten löschen. Öffentliche Setups werden dem \"Gelöschten Benutzer\" zugeordnet.",
"deleteAccount": "Konto löschen",
"deleteConfirmMessage": "Diese Aktion ist dauerhaft. Geben Sie LÖSCHEN ein, um zu bestätigen.",
"deleteConfirmPlaceholder": "Geben Sie LÖSCHEN ein, um zu bestätigen"
} }
} }

View File

@@ -35,9 +35,44 @@
"public": "Öffentlich", "public": "Öffentlich",
"publicDescription": "Sichtbar auf Ihrem Profil" "publicDescription": "Sichtbar auf Ihrem Profil"
}, },
"namePlaceholder": "Neuer Setup-Name...",
"creating": "Erstellen...",
"emptyState": {
"title": "Bauen Sie Ihr perfektes Loadout",
"step1Title": "Setup erstellen",
"step1Description": "Benennen Sie Ihr Loadout für eine bestimmte Tour oder Aktivität",
"step2Title": "Gegenstände hinzufügen",
"step2Description": "Wählen Sie Ausrüstung aus Ihrer Sammlung für das Setup",
"step3Title": "Gewicht verfolgen",
"step3Description": "Gewichtsverteilung anzeigen und Rucksack optimieren"
},
"detail": {
"itemCount": "{{count}} Gegenstände",
"itemCount_one": "{{count}} Gegenstand",
"total": "gesamt",
"cost": "Kosten",
"sharedSetup": "Geteiltes Setup",
"linkNotAvailable": "Link nicht verfügbar",
"linkExpired": "Dieser Freigabelink ist abgelaufen oder nicht mehr gültig.",
"setupNotFound": "Setup nicht gefunden.",
"noItemsTitle": "Keine Gegenstände in diesem Setup",
"noItemsDescription": "Fügen Sie Gegenstände aus Ihrer Sammlung hinzu, um dieses Loadout aufzubauen.",
"addItems": "Gegenstände hinzufügen",
"share": "Teilen",
"deleteSetup": "Setup löschen",
"deleteConfirmMessage": "Möchten Sie {{name}} wirklich löschen? Gegenstände werden nicht aus Ihrer Sammlung entfernt.",
"shareSettings": "Freigabeeinstellungen"
},
"profile": {
"userNotFound": "Benutzer nicht gefunden.",
"backToHome": "Zurück zur Startseite",
"publicSetups": "Öffentliche Setups",
"noPublicSetups": "Noch keine öffentlichen Setups"
},
"impact": { "impact": {
"title": "Auswirkungsvorschau", "title": "Auswirkungsvorschau",
"adding": "Hinzufügen", "adding": "Hinzufügen",
"removing": "Entfernen" "removing": "Entfernen",
"compareWith": "Mit Setup vergleichen..."
} }
} }

View File

@@ -41,5 +41,97 @@
"empty": { "empty": {
"noThreads": "Noch keine Recherche-Threads", "noThreads": "Noch keine Recherche-Threads",
"noCandidates": "Noch keine Kandidaten" "noCandidates": "Noch keine Kandidaten"
},
"card": {
"candidates": "{{count}} Kandidaten",
"candidates_one": "{{count}} Kandidat"
},
"candidateCard": {
"pickAsWinner": "Als Gewinner wählen",
"winner": "Gewinner",
"deleteCandidate": "Kandidat löschen",
"openProductLink": "Produktlink öffnen",
"prosCons": "+/- Notizen"
},
"candidateForm": {
"nameRequired": "Name *",
"weightLabel": "Gewicht (g)",
"priceLabel": "Preis ({{currency}})",
"categoryLabel": "Kategorie",
"notesLabel": "Notizen",
"prosLabel": "Vorteile",
"consLabel": "Nachteile",
"productLinkLabel": "Produktlink",
"namePlaceholder": "z.B. Osprey Talon 22",
"weightPlaceholder": "z.B. 680",
"pricePlaceholder": "z.B. 129,99",
"notesPlaceholder": "Weitere Notizen...",
"prosPlaceholder": "Ein Vorteil pro Zeile...",
"consPlaceholder": "Ein Nachteil pro Zeile...",
"urlPlaceholder": "https://...",
"addCandidate": "Kandidat hinzufügen",
"saveChanges": "Änderungen speichern"
},
"comparisonTable": {
"image": "Bild",
"name": "Name",
"rank": "Rang",
"weight": "Gewicht",
"price": "Preis",
"status": "Status",
"link": "Link",
"notes": "Notizen",
"pros": "Vorteile",
"cons": "Nachteile",
"weightImpact": "Gewichtsauswirkung",
"priceImpact": "Preisauswirkung",
"view": "Ansehen"
},
"addToThread": {
"title": "Zum Thread hinzufügen",
"newThreadTitle": "Neuer Thread + Kandidat",
"thread": "Thread",
"threadName": "Thread-Name",
"newThread": "+ Neuer Thread...",
"backToPicker": "Zurück zur Thread-Auswahl",
"addAsCandidate": "Als Kandidat hinzufügen",
"createAndAdd": "Erstellen & hinzufügen",
"adding": "Hinzufügen...",
"failedToAdd": "Kandidat konnte nicht hinzugefügt werden",
"failedToCreate": "Thread konnte nicht erstellt werden"
},
"statusBadge": {
"researching": "Recherche",
"ordered": "Bestellt",
"arrived": "Angekommen"
},
"planning": {
"title": "Planungs-Threads",
"emptyTitle": "Nächsten Kauf planen",
"createFirst": "Ersten Thread erstellen",
"step1Title": "Thread erstellen",
"step1Description": "Starten Sie einen Recherche-Thread für Ausrüstung, die Sie in Betracht ziehen",
"step2Title": "Kandidaten hinzufügen",
"step2Description": "Fügen Sie Produkte mit Preisen und Gewichten hinzu",
"step3Title": "Gewinner wählen",
"step3Description": "Thread auflösen und der Gewinner kommt in Ihre Sammlung"
},
"detail": {
"notFound": "Thread nicht gefunden",
"backToPlanning": "Zurück zur Planung",
"statusActive": "Aktiv",
"statusResolved": "Abgeschlossen",
"resolutionBanner": "wurde als Gewinner gewählt und Ihrer Sammlung hinzugefügt.",
"addCandidate": "Kandidat hinzufügen",
"emptyCandidatesTitle": "Noch keine Kandidaten",
"emptyCandidatesDescription": "Fügen Sie Ihren ersten Kandidaten hinzu, um zu vergleichen.",
"listView": "Listenansicht",
"gridView": "Gitteransicht",
"compareView": "Vergleichsansicht",
"addCandidateModal": {
"title": "Kandidat hinzufügen",
"submit": "Kandidat hinzufügen",
"adding": "Hinzufügen..."
}
} }
} }

View File

@@ -0,0 +1,21 @@
{
"discover": "Discover",
"searchPlaceholder": "Search the catalog...",
"filter": {
"tags": "Tags",
"weight": "Weight",
"price": "Price",
"weightRange": "Weight range",
"priceRange": "Price range",
"min": "Min: {{value}}",
"max": "Max: {{value}}",
"reset": "Reset",
"clearAll": "Clear all",
"listView": "List view",
"gridView": "Grid view"
},
"empty": {
"noResults": "No items found matching your search",
"noCatalogItems": "No items in the global catalog yet"
}
}

View File

@@ -100,6 +100,39 @@
"openProductLink": "Open product link", "openProductLink": "Open product link",
"removeFromSetup": "Remove from setup" "removeFromSetup": "Remove from setup"
}, },
"addToCollection": {
"title": "Add to Collection",
"categoryLabel": "Category",
"notesLabel": "Notes",
"notesPlaceholder": "Personal notes (optional)",
"purchasePriceLabel": "Purchase Price ({{currency}})",
"purchasePricePlaceholder": "Purchase price (optional)",
"selectCategory": "Please select a category",
"addButton": "Add to Collection",
"addingButton": "Adding...",
"added": "Added to Collection",
"failedToAdd": "Failed to add item"
},
"item": {
"backToSetup": "Back to setup",
"backToCollection": "Back to collection",
"notFound": "Item not found",
"nameFromCatalog": "Name and brand are from the catalog",
"removeFromCollection": "Remove from Collection",
"weightLabel": "Weight (g)",
"msrpLabel": "MSRP",
"priceLabel": "Price ({{currency}})",
"quantityLabel": "Quantity",
"categoryLabel": "Category",
"notesLabel": "Notes",
"notesPlaceholder": "Add notes...",
"productUrlLabel": "Product URL",
"urlPlaceholder": "https://...",
"viewProduct": "View product",
"qty": "Qty: {{count}}",
"added": "Added",
"updated": "Updated"
},
"profileSection": { "profileSection": {
"title": "Profile", "title": "Profile",
"subtitle": "Your public profile information", "subtitle": "Your public profile information",

View File

@@ -27,6 +27,7 @@
"loading": "Loading...", "loading": "Loading...",
"addItem": "Add Item", "addItem": "Add Item",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"duplicate": "Duplicate",
"revoke": "Revoke", "revoke": "Revoke",
"skipStep": "Skip this step" "skipStep": "Skip this step"
}, },

View File

@@ -1,6 +1,34 @@
{ {
"title": "Setups", "title": "Setups",
"create": "New Setup", "create": "New Setup",
"namePlaceholder": "New setup name...",
"creating": "Creating...",
"emptyState": {
"title": "Build your perfect loadout",
"step1Title": "Create a setup",
"step1Description": "Name your loadout for a specific trip or activity",
"step2Title": "Add items",
"step2Description": "Pick gear from your collection to include in the setup",
"step3Title": "Track weight",
"step3Description": "See weight breakdown and optimize your pack"
},
"detail": {
"itemCount": "{{count}} items",
"itemCount_one": "{{count}} item",
"total": "total",
"cost": "cost",
"sharedSetup": "Shared setup",
"linkNotAvailable": "Link not available",
"linkExpired": "This share link has expired or is no longer valid.",
"setupNotFound": "Setup not found.",
"noItemsTitle": "No items in this setup",
"noItemsDescription": "Add items from your collection to build this loadout.",
"addItems": "Add Items",
"share": "Share",
"deleteSetup": "Delete Setup",
"deleteConfirmMessage": "Are you sure you want to delete {{name}}? This will not remove items from your collection.",
"shareSettings": "Share settings"
},
"empty": { "empty": {
"title": "No setups yet", "title": "No setups yet",
"description": "Create a setup to organize gear for specific trips or activities." "description": "Create a setup to organize gear for specific trips or activities."
@@ -37,6 +65,12 @@
"public": "Public", "public": "Public",
"publicDescription": "Visible on your profile" "publicDescription": "Visible on your profile"
}, },
"profile": {
"userNotFound": "User not found.",
"backToHome": "Back to home",
"publicSetups": "Public Setups",
"noPublicSetups": "No public setups yet"
},
"impact": { "impact": {
"title": "Impact Preview", "title": "Impact Preview",
"adding": "Adding", "adding": "Adding",

View File

@@ -56,6 +56,7 @@
"candidateForm": { "candidateForm": {
"nameRequired": "Name *", "nameRequired": "Name *",
"weightLabel": "Weight (g)", "weightLabel": "Weight (g)",
"priceLabel": "Price ({{currency}})",
"categoryLabel": "Category", "categoryLabel": "Category",
"notesLabel": "Notes", "notesLabel": "Notes",
"prosLabel": "Pros", "prosLabel": "Pros",
@@ -104,6 +105,24 @@
"ordered": "Ordered", "ordered": "Ordered",
"arrived": "Arrived" "arrived": "Arrived"
}, },
"detail": {
"notFound": "Thread not found",
"backToPlanning": "Back to planning",
"statusActive": "Active",
"statusResolved": "Resolved",
"resolutionBanner": "was picked as the winner and added to your collection.",
"addCandidate": "Add Candidate",
"emptyCandidatesTitle": "No candidates yet",
"emptyCandidatesDescription": "Add your first candidate to start comparing.",
"listView": "List view",
"gridView": "Grid view",
"compareView": "Compare view",
"addCandidateModal": {
"title": "Add Candidate",
"submit": "Add Candidate",
"adding": "Adding..."
}
},
"planning": { "planning": {
"title": "Planning Threads", "title": "Planning Threads",
"emptyTitle": "Plan your next purchase", "emptyTitle": "Plan your next purchase",

View File

@@ -1,6 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { CollectionView } from "../../components/CollectionView"; import { CollectionView } from "../../components/CollectionView";
import { PlanningView } from "../../components/PlanningView"; import { PlanningView } from "../../components/PlanningView";
@@ -15,10 +16,6 @@ export const Route = createFileRoute("/collection/")({
}); });
const TAB_ORDER = ["gear", "planning"] as const; const TAB_ORDER = ["gear", "planning"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
};
const slideVariants = { const slideVariants = {
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }), enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
@@ -27,9 +24,15 @@ const slideVariants = {
}; };
function CollectionPage() { function CollectionPage() {
const { t } = useTranslation("collection");
const { tab } = Route.useSearch(); const { tab } = Route.useSearch();
const prevTab = useRef(tab); const prevTab = useRef(tab);
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: t("gear"),
planning: t("planning"),
};
const direction = const direction =
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1; TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
prevTab.current = tab; prevTab.current = tab;
@@ -39,18 +42,18 @@ function CollectionPage() {
{/* Tab navigation */} {/* Tab navigation */}
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5"> <div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
{TAB_ORDER.map((t) => ( {TAB_ORDER.map((tabKey) => (
<Link <Link
key={t} key={tabKey}
to="/collection" to="/collection"
search={{ tab: t }} search={{ tab: tabKey }}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${ className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
tab === t tab === tabKey
? "bg-gray-700 text-white" ? "bg-gray-700 text-white"
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
{TAB_LABELS[t]} {TAB_LABELS[tabKey]}
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react"; import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { GearImage } from "../../components/GearImage"; import { GearImage } from "../../components/GearImage";
@@ -18,6 +19,7 @@ export const Route = createFileRoute("/global-items/")({
type ViewMode = "grid" | "list"; type ViewMode = "grid" | "list";
function GlobalItemsCatalog() { function GlobalItemsCatalog() {
const { t } = useTranslation("catalog");
const { q } = Route.useSearch(); const { q } = Route.useSearch();
const [searchInput, setSearchInput] = useState(q ?? ""); const [searchInput, setSearchInput] = useState(q ?? "");
@@ -128,7 +130,7 @@ function GlobalItemsCatalog() {
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors shrink-0" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors shrink-0"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Discover</span> <span className="hidden sm:inline">{t("discover")}</span>
</Link> </Link>
{/* Search input */} {/* Search input */}
@@ -137,7 +139,7 @@ function GlobalItemsCatalog() {
type="text" type="text"
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search the catalog..." placeholder={t("searchPlaceholder")}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent transition-colors" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent transition-colors"
/> />
{searchInput && ( {searchInput && (
@@ -161,7 +163,7 @@ function GlobalItemsCatalog() {
? "bg-white text-gray-900 shadow-sm" ? "bg-white text-gray-900 shadow-sm"
: "text-gray-400 hover:text-gray-600" : "text-gray-400 hover:text-gray-600"
}`} }`}
title="List view" title={t("filter.listView")}
> >
<LayoutList className="w-4 h-4" /> <LayoutList className="w-4 h-4" />
</button> </button>
@@ -173,7 +175,7 @@ function GlobalItemsCatalog() {
? "bg-white text-gray-900 shadow-sm" ? "bg-white text-gray-900 shadow-sm"
: "text-gray-400 hover:text-gray-600" : "text-gray-400 hover:text-gray-600"
}`} }`}
title="Grid view" title={t("filter.gridView")}
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
@@ -201,7 +203,7 @@ function GlobalItemsCatalog() {
: "bg-white text-gray-600 border-gray-200 hover:border-gray-300" : "bg-white text-gray-600 border-gray-200 hover:border-gray-300"
}`} }`}
> >
Tags {t("filter.tags")}
{selectedTags.length > 0 && ( {selectedTags.length > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-blue-500 text-white text-[10px] font-bold"> <span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-blue-500 text-white text-[10px] font-bold">
{selectedTags.length} {selectedTags.length}
@@ -250,7 +252,7 @@ function GlobalItemsCatalog() {
: "bg-white text-gray-600 border-gray-200 hover:border-gray-300" : "bg-white text-gray-600 border-gray-200 hover:border-gray-300"
}`} }`}
> >
Weight {t("filter.weight")}
{(weightMin > 0 || weightMax < 5000) && ( {(weightMin > 0 || weightMax < 5000) && (
<span className="ml-0.5 text-blue-500"> <span className="ml-0.5 text-blue-500">
{weightMin > 0 && weightMax < 5000 {weightMin > 0 && weightMax < 5000
@@ -265,7 +267,7 @@ function GlobalItemsCatalog() {
{weightFilterOpen && ( {weightFilterOpen && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-56"> <div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-56">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Weight range {t("filter.weightRange")}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
@@ -318,7 +320,7 @@ function GlobalItemsCatalog() {
}} }}
className="mt-2 text-xs text-gray-400 hover:text-gray-600" className="mt-2 text-xs text-gray-400 hover:text-gray-600"
> >
Reset {t("filter.reset")}
</button> </button>
)} )}
</div> </div>
@@ -340,7 +342,7 @@ function GlobalItemsCatalog() {
: "bg-white text-gray-600 border-gray-200 hover:border-gray-300" : "bg-white text-gray-600 border-gray-200 hover:border-gray-300"
}`} }`}
> >
Price {t("filter.price")}
{(priceMin > 0 || priceMax < 100000) && ( {(priceMin > 0 || priceMax < 100000) && (
<span className="ml-0.5 text-green-600"> <span className="ml-0.5 text-green-600">
{priceMin > 0 && priceMax < 100000 {priceMin > 0 && priceMax < 100000
@@ -355,7 +357,7 @@ function GlobalItemsCatalog() {
{priceFilterOpen && ( {priceFilterOpen && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-56"> <div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-56">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Price range {t("filter.priceRange")}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
@@ -408,7 +410,7 @@ function GlobalItemsCatalog() {
}} }}
className="mt-2 text-xs text-gray-400 hover:text-gray-600" className="mt-2 text-xs text-gray-400 hover:text-gray-600"
> >
Reset {t("filter.reset")}
</button> </button>
)} )}
</div> </div>
@@ -467,7 +469,7 @@ function GlobalItemsCatalog() {
onClick={clearAllFilters} onClick={clearAllFilters}
className="text-xs text-gray-400 hover:text-gray-600 px-1 transition-colors" className="text-xs text-gray-400 hover:text-gray-600 px-1 transition-colors"
> >
Clear all {t("filter.clearAll")}
</button> </button>
)} )}
</div> </div>
@@ -644,6 +646,7 @@ function SkeletonList() {
// ── Empty State ──────────────────────────────────────────────────────── // ── Empty State ────────────────────────────────────────────────────────
function EmptyState({ hasQuery }: { hasQuery: boolean }) { function EmptyState({ hasQuery }: { hasQuery: boolean }) {
const { t } = useTranslation("catalog");
return ( return (
<div className="flex flex-col items-center justify-center py-20 px-4"> <div className="flex flex-col items-center justify-center py-20 px-4">
<svg <svg
@@ -661,8 +664,8 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
</svg> </svg>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">
{hasQuery {hasQuery
? "No items found matching your search" ? t("empty.noResults")
: "No items in the global catalog yet"} : t("empty.noCatalogItems")}
</p> </p>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { CategoryPicker } from "../../components/CategoryPicker"; import { CategoryPicker } from "../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../components/GearImage"; import { GearImage, imageContainerBg } from "../../components/GearImage";
@@ -36,6 +37,7 @@ interface EditFormState {
} }
function ItemDetail() { function ItemDetail() {
const { t } = useTranslation(["collection", "common"]);
const { itemId } = Route.useParams(); const { itemId } = Route.useParams();
const { setup: setupId, share: shareToken } = Route.useSearch(); const { setup: setupId, share: shareToken } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -205,7 +207,7 @@ function ItemDetail() {
search: shareToken ? { share: shareToken } : {}, search: shareToken ? { share: shareToken } : {},
} }
: { to: "/collection" as const, params: {}, search: {} }; : { to: "/collection" as const, params: {}, search: {} };
const backLabel = setupId ? "Back to setup" : "Back to collection"; const backLabel = setupId ? t("collection:item.backToSetup") : t("collection:item.backToCollection");
if (error || !item) { if (error || !item) {
return ( return (
@@ -219,7 +221,7 @@ function ItemDetail() {
&larr; {backLabel} &larr; {backLabel}
</Link> </Link>
<div className="text-center py-16"> <div className="text-center py-16">
<p className="text-sm text-gray-500">Item not found</p> <p className="text-sm text-gray-500">{t("collection:item.notFound")}</p>
</div> </div>
</div> </div>
); );
@@ -249,7 +251,7 @@ function ItemDetail() {
disabled={duplicateItem.isPending} disabled={duplicateItem.isPending}
className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors" className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
> >
Duplicate {t("common:actions.duplicate")}
</button> </button>
{/* Duplicate — mobile */} {/* Duplicate — mobile */}
<button <button
@@ -257,8 +259,8 @@ function ItemDetail() {
onClick={handleDuplicate} onClick={handleDuplicate}
disabled={duplicateItem.isPending} disabled={duplicateItem.isPending}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors" className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
aria-label="Duplicate" aria-label={t("common:actions.duplicate")}
title="Duplicate" title={t("common:actions.duplicate")}
> >
<LucideIcon name="copy" size={16} /> <LucideIcon name="copy" size={16} />
</button> </button>
@@ -268,15 +270,15 @@ function ItemDetail() {
onClick={handleDelete} onClick={handleDelete}
className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
> >
{isReference ? "Remove from Collection" : "Delete"} {isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
</button> </button>
{/* Delete — mobile */} {/* Delete — mobile */}
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
aria-label={isReference ? "Remove from Collection" : "Delete"} aria-label={isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
title={isReference ? "Remove from Collection" : "Delete"} title={isReference ? t("collection:item.removeFromCollection") : t("common:actions.delete")}
> >
<LucideIcon name="trash-2" size={16} /> <LucideIcon name="trash-2" size={16} />
</button> </button>
@@ -286,15 +288,15 @@ function ItemDetail() {
onClick={enterEditMode} onClick={enterEditMode}
className="hidden md:inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors" className="hidden md:inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
> >
Edit {t("common:actions.edit")}
</button> </button>
{/* Edit — mobile */} {/* Edit — mobile */}
<button <button
type="button" type="button"
onClick={enterEditMode} onClick={enterEditMode}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors" className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Edit" aria-label={t("common:actions.edit")}
title="Edit" title={t("common:actions.edit")}
> >
<LucideIcon name="pencil" size={16} /> <LucideIcon name="pencil" size={16} />
</button> </button>
@@ -307,7 +309,7 @@ function ItemDetail() {
onClick={cancelEdit} onClick={cancelEdit}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors" className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
> >
Cancel {t("common:actions.cancel")}
</button> </button>
<button <button
type="button" type="button"
@@ -315,7 +317,7 @@ function ItemDetail() {
disabled={updateItem.isPending || !form.name.trim()} disabled={updateItem.isPending || !form.name.trim()}
className="px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50" className="px-4 py-1.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50"
> >
{updateItem.isPending ? "Saving..." : "Save"} {updateItem.isPending ? t("common:actions.saving") : t("common:actions.save")}
</button> </button>
</div> </div>
)} )}
@@ -415,7 +417,7 @@ function ItemDetail() {
: item.name} : item.name}
</h1> </h1>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">
Name and brand are from the catalog {t("collection:item.nameFromCatalog")}
</p> </p>
</> </>
) : ( ) : (
@@ -463,7 +465,7 @@ function ItemDetail() {
{item.weightGrams != null && ( {item.weightGrams != null && (
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Weight (g) {t("collection:item.weightLabel")}
</label> </label>
<p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500"> <p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500">
{item.weightGrams} {item.weightGrams}
@@ -473,7 +475,7 @@ function ItemDetail() {
{item.priceCents != null && ( {item.priceCents != null && (
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
MSRP {t("collection:item.msrpLabel")}
</label> </label>
<p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500"> <p className="py-2 px-3 bg-gray-50 border border-gray-100 rounded-lg text-sm text-gray-500">
{price(item.priceCents)} {price(item.priceCents)}
@@ -485,7 +487,7 @@ function ItemDetail() {
<> <>
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Weight (g) {t("collection:item.weightLabel")}
</label> </label>
<input <input
type="number" type="number"
@@ -502,7 +504,7 @@ function ItemDetail() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
{`Price (${currency})`} {t("collection:item.priceLabel", { currency })}
</label> </label>
<input <input
type="number" type="number"
@@ -522,7 +524,7 @@ function ItemDetail() {
)} )}
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Quantity {t("collection:item.quantityLabel")}
</label> </label>
<input <input
type="number" type="number"
@@ -539,7 +541,7 @@ function ItemDetail() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Category {t("collection:item.categoryLabel")}
</label> </label>
<CategoryPicker <CategoryPicker
value={form.categoryId} value={form.categoryId}
@@ -569,7 +571,7 @@ function ItemDetail() {
</span> </span>
{item.quantity > 1 && ( {item.quantity > 1 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-50 text-purple-500"> <span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-50 text-purple-500">
Qty: {item.quantity} {t("collection:item.qty", { count: item.quantity })}
</span> </span>
)} )}
</div> </div>
@@ -579,21 +581,21 @@ function ItemDetail() {
{isEditing ? ( {isEditing ? (
<div className="mb-6"> <div className="mb-6">
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Notes {t("collection:item.notesLabel")}
</label> </label>
<textarea <textarea
value={form.notes} value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={4} rows={4}
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none" className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
placeholder="Add notes..." placeholder={t("collection:item.notesPlaceholder")}
/> />
</div> </div>
) : ( ) : (
item.notes && ( item.notes && (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2"> <h2 className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
Notes {t("collection:item.notesLabel")}
</h2> </h2>
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-gray-600 text-sm leading-relaxed">
{item.notes} {item.notes}
@@ -606,7 +608,7 @@ function ItemDetail() {
{isEditing ? ( {isEditing ? (
<div className="mb-6"> <div className="mb-6">
<label className="block text-xs font-medium text-gray-500 mb-1"> <label className="block text-xs font-medium text-gray-500 mb-1">
Product URL {t("collection:item.productUrlLabel")}
</label> </label>
<input <input
type="url" type="url"
@@ -618,7 +620,7 @@ function ItemDetail() {
})) }))
} }
className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="https://..." placeholder={t("collection:item.urlPlaceholder")}
/> />
</div> </div>
) : ( ) : (
@@ -642,7 +644,7 @@ function ItemDetail() {
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/> />
</svg> </svg>
View product {t("collection:item.viewProduct")}
</button> </button>
</div> </div>
) )
@@ -653,7 +655,7 @@ function ItemDetail() {
<div className="border-t border-gray-100 pt-4 mt-8"> <div className="border-t border-gray-100 pt-4 mt-8">
<div className="flex gap-6 text-xs text-gray-400"> <div className="flex gap-6 text-xs text-gray-400">
<span> <span>
Added{" "} {t("collection:item.added")}{" "}
{new Date(item.createdAt).toLocaleDateString(undefined, { {new Date(item.createdAt).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
@@ -661,7 +663,7 @@ function ItemDetail() {
})} })}
</span> </span>
<span> <span>
Updated{" "} {t("collection:item.updated")}{" "}
{new Date(item.updatedAt).toLocaleDateString(undefined, { {new Date(item.updatedAt).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
@@ -27,6 +28,7 @@ export const Route = createFileRoute("/setups/$setupId")({
}); });
function SetupDetailPage() { function SetupDetailPage() {
const { t } = useTranslation(["setups", "common"]);
const { setupId } = Route.useParams(); const { setupId } = Route.useParams();
const { share: shareToken } = Route.useSearch(); const { share: shareToken } = Route.useSearch();
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
@@ -84,10 +86,10 @@ function SetupDetailPage() {
className="text-gray-300 mx-auto mb-4" className="text-gray-300 mx-auto mb-4"
/> />
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Link not available {t("setups:detail.linkNotAvailable")}
</h2> </h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
This share link has expired or is no longer valid. {t("setups:detail.linkExpired")}
</p> </p>
</div> </div>
); );
@@ -96,7 +98,7 @@ function SetupDetailPage() {
if (!setup) { if (!setup) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">Setup not found.</p> <p className="text-gray-500">{t("setups:detail.setupNotFound")}</p>
</div> </div>
); );
} }
@@ -156,7 +158,7 @@ function SetupDetailPage() {
{isSharedView && setup && ( {isSharedView && setup && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8"> <div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<LucideIcon name="link" size={16} className="text-blue-500" /> <LucideIcon name="link" size={16} className="text-blue-500" />
<span className="text-sm text-blue-700">Shared setup</span> <span className="text-sm text-blue-700">{t("setups:detail.sharedSetup")}</span>
</div> </div>
)} )}
@@ -178,19 +180,19 @@ function SetupDetailPage() {
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-4 text-sm text-gray-500">
<span> <span>
<span className="font-medium text-gray-700">{itemCount}</span>{" "} <span className="font-medium text-gray-700">{itemCount}</span>{" "}
{itemCount === 1 ? "item" : "items"} {t("setups:detail.itemCount", { count: itemCount })}
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{weight(totalWeight)} {weight(totalWeight)}
</span>{" "} </span>{" "}
total {t("setups:detail.total")}
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{price(totalCost)} {price(totalCost)}
</span>{" "} </span>{" "}
cost {t("setups:detail.cost")}
</span> </span>
</div> </div>
</div> </div>
@@ -206,15 +208,15 @@ function SetupDetailPage() {
className="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors" className="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
<LucideIcon name="plus" size={16} /> <LucideIcon name="plus" size={16} />
Add Items {t("setups:detail.addItems")}
</button> </button>
{/* Add Items — mobile */} {/* Add Items — mobile */}
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 bg-gray-700 hover:bg-gray-800 text-white rounded-lg transition-colors" className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 bg-gray-700 hover:bg-gray-800 text-white rounded-lg transition-colors"
aria-label="Add Items" aria-label={t("setups:detail.addItems")}
title="Add Items" title={t("setups:detail.addItems")}
> >
<LucideIcon name="plus" size={16} /> <LucideIcon name="plus" size={16} />
</button> </button>
@@ -241,7 +243,7 @@ function SetupDetailPage() {
} }
size={16} size={16}
/> />
Share {t("setups:detail.share")}
</button> </button>
{/* Share button — mobile */} {/* Share button — mobile */}
<button <button
@@ -254,8 +256,8 @@ function SetupDetailPage() {
? "text-blue-600 bg-blue-50 hover:bg-blue-100" ? "text-blue-600 bg-blue-50 hover:bg-blue-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100" : "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`} }`}
aria-label="Share settings" aria-label={t("setups:detail.shareSettings")}
title="Share settings" title={t("setups:detail.shareSettings")}
> >
<LucideIcon <LucideIcon
name={ name={
@@ -276,15 +278,15 @@ function SetupDetailPage() {
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="hidden md:inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="hidden md:inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
> >
Delete Setup {t("setups:detail.deleteSetup")}
</button> </button>
{/* Delete Setup — mobile */} {/* Delete Setup — mobile */}
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
aria-label="Delete Setup" aria-label={t("setups:detail.deleteSetup")}
title="Delete Setup" title={t("setups:detail.deleteSetup")}
> >
<LucideIcon name="trash-2" size={16} /> <LucideIcon name="trash-2" size={16} />
</button> </button>
@@ -303,10 +305,10 @@ function SetupDetailPage() {
/> />
</div> </div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
No items in this setup {t("setups:detail.noItemsTitle")}
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Add items from your collection to build this loadout. {t("setups:detail.noItemsDescription")}
</p> </p>
{showOwnerControls && ( {showOwnerControls && (
<button <button
@@ -314,7 +316,7 @@ function SetupDetailPage() {
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
> >
Add Items {t("setups:detail.addItems")}
</button> </button>
)} )}
</div> </div>
@@ -427,12 +429,10 @@ function SetupDetailPage() {
/> />
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full"> <div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Setup {t("setups:detail.deleteSetup")}
</h3> </h3>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "} {t("setups:detail.deleteConfirmMessage", { name: setup.name })}
<span className="font-medium">{setup.name}</span>? This will not
remove items from your collection.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@@ -440,7 +440,7 @@ function SetupDetailPage() {
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
> >
Cancel {t("common:actions.cancel")}
</button> </button>
<button <button
type="button" type="button"
@@ -448,7 +448,7 @@ function SetupDetailPage() {
disabled={deleteSetup.isPending} disabled={deleteSetup.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
> >
{deleteSetup.isPending ? "Deleting..." : "Delete"} {deleteSetup.isPending ? t("common:actions.deleting") : t("common:actions.delete")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { Reorder } from "framer-motion"; import { Reorder } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CandidateCard } from "../../../components/CandidateCard"; import { CandidateCard } from "../../../components/CandidateCard";
import { CandidateListItem } from "../../../components/CandidateListItem"; import { CandidateListItem } from "../../../components/CandidateListItem";
import { CategoryPicker } from "../../../components/CategoryPicker"; import { CategoryPicker } from "../../../components/CategoryPicker";
@@ -24,6 +25,7 @@ export const Route = createFileRoute("/threads/$threadId/")({
}); });
function ThreadDetailPage() { function ThreadDetailPage() {
const { t } = useTranslation(["threads", "common"]);
const { threadId: threadIdParam } = Route.useParams(); const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam); const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId); const { data: thread, isLoading, isError } = useThread(threadId);
@@ -70,14 +72,14 @@ function ThreadDetailPage() {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Thread not found {t("threads:detail.notFound")}
</h2> </h2>
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-gray-600 hover:text-gray-700" className="text-sm text-gray-600 hover:text-gray-700"
> >
Back to planning {t("threads:detail.backToPlanning")}
</Link> </Link>
</div> </div>
); );
@@ -106,7 +108,7 @@ function ThreadDetailPage() {
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
&larr; Back to planning &larr; {t("threads:detail.backToPlanning")}
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1> <h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
@@ -117,7 +119,7 @@ function ThreadDetailPage() {
: "bg-gray-100 text-gray-500" : "bg-gray-100 text-gray-500"
}`} }`}
> >
{isActive ? "Active" : "Resolved"} {isActive ? t("threads:detail.statusActive") : t("threads:detail.statusResolved")}
</span> </span>
</div> </div>
</div> </div>
@@ -126,8 +128,8 @@ function ThreadDetailPage() {
{!isActive && winningCandidate && ( {!isActive && winningCandidate && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl"> <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<span className="font-medium">{winningCandidate.name}</span> was <span className="font-medium">{winningCandidate.name}</span>{" "}
picked as the winner and added to your collection. {t("threads:detail.resolutionBanner")}
</p> </p>
</div> </div>
)} )}
@@ -153,7 +155,7 @@ function ThreadDetailPage() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Candidate {t("threads:detail.addCandidate")}
</button> </button>
)} )}
{thread.candidates.length > 0 && ( {thread.candidates.length > 0 && (
@@ -166,7 +168,7 @@ function ThreadDetailPage() {
? "bg-gray-200 text-gray-900" ? "bg-gray-200 text-gray-900"
: "text-gray-400 hover:text-gray-600" : "text-gray-400 hover:text-gray-600"
}`} }`}
title="List view" title={t("threads:detail.listView")}
> >
<LucideIcon name="layout-list" size={16} /> <LucideIcon name="layout-list" size={16} />
</button> </button>
@@ -178,7 +180,7 @@ function ThreadDetailPage() {
? "bg-gray-200 text-gray-900" ? "bg-gray-200 text-gray-900"
: "text-gray-400 hover:text-gray-600" : "text-gray-400 hover:text-gray-600"
}`} }`}
title="Grid view" title={t("threads:detail.gridView")}
> >
<LucideIcon name="layout-grid" size={16} /> <LucideIcon name="layout-grid" size={16} />
</button> </button>
@@ -191,7 +193,7 @@ function ThreadDetailPage() {
? "bg-gray-200 text-gray-900" ? "bg-gray-200 text-gray-900"
: "text-gray-400 hover:text-gray-600" : "text-gray-400 hover:text-gray-600"
}`} }`}
title="Compare view" title={t("threads:detail.compareView")}
> >
<LucideIcon name="columns-3" size={16} /> <LucideIcon name="columns-3" size={16} />
</button> </button>
@@ -212,10 +214,10 @@ function ThreadDetailPage() {
/> />
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <h3 className="text-lg font-semibold text-gray-900 mb-1">
No candidates yet {t("threads:detail.emptyCandidatesTitle")}
</h3> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Add your first candidate to start comparing. {t("threads:detail.emptyCandidatesDescription")}
</p> </p>
</div> </div>
) : candidateViewMode === "compare" ? ( ) : candidateViewMode === "compare" ? (
@@ -340,6 +342,7 @@ const INITIAL_MODAL_FORM: ModalFormData = {
}; };
function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) { function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
const { t } = useTranslation(["threads", "common"]);
const createCandidate = useCreateCandidate(threadId); const createCandidate = useCreateCandidate(threadId);
const { currency } = useCurrency(); const { currency } = useCurrency();
const [form, setForm] = useState<ModalFormData>(INITIAL_MODAL_FORM); const [form, setForm] = useState<ModalFormData>(INITIAL_MODAL_FORM);
@@ -348,26 +351,26 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
function validate(): boolean { function validate(): boolean {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.name.trim()) { if (!form.name.trim()) {
newErrors.name = "Name is required"; newErrors.name = t("common:errors.nameRequired");
} }
if ( if (
form.weightGrams && form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0) (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) { ) {
newErrors.weightGrams = "Must be a positive number"; newErrors.weightGrams = t("common:errors.positiveNumber");
} }
if ( if (
form.priceDollars && form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0) (Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) { ) {
newErrors.priceDollars = "Must be a positive number"; newErrors.priceDollars = t("common:errors.positiveNumber");
} }
if ( if (
form.productUrl && form.productUrl &&
form.productUrl.trim() !== "" && form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//) !form.productUrl.match(/^https?:\/\//)
) { ) {
newErrors.productUrl = "Must be a valid URL (https://...)"; newErrors.productUrl = t("common:errors.validUrl");
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
@@ -416,7 +419,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
> >
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Add Candidate</h2> <h2 className="text-lg font-semibold text-gray-900">{t("threads:detail.addCandidateModal.title")}</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -441,7 +444,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-name" htmlFor="modal-candidate-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Name * {t("threads:candidateForm.nameRequired")}
</label> </label>
<input <input
id="modal-candidate-name" id="modal-candidate-name"
@@ -449,7 +452,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
value={form.name} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Osprey Talon 22" placeholder={t("threads:candidateForm.namePlaceholder")}
autoFocus autoFocus
/> />
{errors.name && ( {errors.name && (
@@ -464,7 +467,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-weight" htmlFor="modal-candidate-weight"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Weight (g) {t("threads:candidateForm.weightLabel")}
</label> </label>
<input <input
id="modal-candidate-weight" id="modal-candidate-weight"
@@ -479,7 +482,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
})) }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 680" placeholder={t("threads:candidateForm.weightPlaceholder")}
/> />
{errors.weightGrams && ( {errors.weightGrams && (
<p className="mt-1 text-xs text-red-500"> <p className="mt-1 text-xs text-red-500">
@@ -492,7 +495,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-price" htmlFor="modal-candidate-price"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
{`Price (${currency})`} {t("threads:candidateForm.priceLabel", { currency })}
</label> </label>
<input <input
id="modal-candidate-price" id="modal-candidate-price"
@@ -507,7 +510,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
})) }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 129.99" placeholder={t("threads:candidateForm.pricePlaceholder")}
/> />
{errors.priceDollars && ( {errors.priceDollars && (
<p className="mt-1 text-xs text-red-500"> <p className="mt-1 text-xs text-red-500">
@@ -520,7 +523,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
{/* Category */} {/* Category */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Category {t("threads:candidateForm.categoryLabel")}
</label> </label>
<CategoryPicker <CategoryPicker
value={form.categoryId} value={form.categoryId}
@@ -534,7 +537,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-notes" htmlFor="modal-candidate-notes"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Notes {t("threads:candidateForm.notesLabel")}
</label> </label>
<textarea <textarea
id="modal-candidate-notes" id="modal-candidate-notes"
@@ -544,7 +547,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
} }
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
placeholder="Any additional notes..." placeholder={t("threads:candidateForm.notesPlaceholder")}
/> />
</div> </div>
@@ -554,7 +557,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-pros" htmlFor="modal-candidate-pros"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Pros {t("threads:candidateForm.prosLabel")}
</label> </label>
<textarea <textarea
id="modal-candidate-pros" id="modal-candidate-pros"
@@ -562,7 +565,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
placeholder="One pro per line..." placeholder={t("threads:candidateForm.prosPlaceholder")}
/> />
</div> </div>
@@ -572,7 +575,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-cons" htmlFor="modal-candidate-cons"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Cons {t("threads:candidateForm.consLabel")}
</label> </label>
<textarea <textarea
id="modal-candidate-cons" id="modal-candidate-cons"
@@ -580,7 +583,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
placeholder="One con per line..." placeholder={t("threads:candidateForm.consPlaceholder")}
/> />
</div> </div>
@@ -590,7 +593,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
htmlFor="modal-candidate-url" htmlFor="modal-candidate-url"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Product Link {t("threads:candidateForm.productLinkLabel")}
</label> </label>
<input <input
id="modal-candidate-url" id="modal-candidate-url"
@@ -600,7 +603,7 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
setForm((f) => ({ ...f, productUrl: e.target.value })) setForm((f) => ({ ...f, productUrl: e.target.value }))
} }
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="https://..." placeholder={t("threads:candidateForm.urlPlaceholder")}
/> />
{errors.productUrl && ( {errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p> <p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
@@ -614,14 +617,14 @@ function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
disabled={createCandidate.isPending} disabled={createCandidate.isPending}
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
> >
{createCandidate.isPending ? "Adding..." : "Add Candidate"} {createCandidate.isPending ? t("threads:detail.addCandidateModal.adding") : t("threads:detail.addCandidateModal.submit")}
</button> </button>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="py-2.5 px-4 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors" className="py-2.5 px-4 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
> >
Cancel {t("common:actions.cancel")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,4 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { PublicSetupCard } from "../../components/PublicSetupCard"; import { PublicSetupCard } from "../../components/PublicSetupCard";
import { usePublicProfile } from "../../hooks/useProfile"; import { usePublicProfile } from "../../hooks/useProfile";
@@ -7,6 +8,7 @@ export const Route = createFileRoute("/users/$userId")({
}); });
function PublicProfilePage() { function PublicProfilePage() {
const { t } = useTranslation(["setups", "common"]);
const { userId } = Route.useParams(); const { userId } = Route.useParams();
const numericId = Number(userId); const numericId = Number(userId);
const { data: profile, isLoading, isError } = usePublicProfile(numericId); const { data: profile, isLoading, isError } = usePublicProfile(numericId);
@@ -35,12 +37,12 @@ function PublicProfilePage() {
if (isError || !profile) { if (isError || !profile) {
return ( return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">User not found.</p> <p className="text-gray-500">{t("profile.userNotFound")}</p>
<Link <Link
to="/" to="/"
className="text-sm text-gray-500 hover:text-gray-700 mt-4 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mt-4 inline-block"
> >
&larr; Back to home &larr; {t("profile.backToHome")}
</Link> </Link>
</div> </div>
); );
@@ -83,11 +85,11 @@ function PublicProfilePage() {
{/* Public setups */} {/* Public setups */}
<div> <div>
<h2 className="text-base font-medium text-gray-900 mb-4"> <h2 className="text-base font-medium text-gray-900 mb-4">
Public Setups {t("profile.publicSetups")}
</h2> </h2>
{profile.setups.length === 0 ? ( {profile.setups.length === 0 ? (
<p className="text-sm text-gray-400 py-8 text-center"> <p className="text-sm text-gray-400 py-8 text-center">
No public setups yet {t("profile.noPublicSetups")}
</p> </p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">