feat(34-02): extract hardcoded strings from thread/candidate components

- CandidateCard: replace all hardcoded titles and badge text with t()
- CandidateListItem: add useTranslation, replace winner/delete/open labels and +/- Notes badge
- CandidateForm: add useTranslation, replace all form labels, placeholders, validation errors, submit button
- ComparisonTable: move STATUS_LABELS inside component with t(), replace all ATTRIBUTE_ROWS labels, View button, impact row labels
- StatusBadge: refactor STATUS_CONFIG to STATUS_ICONS + runtime STATUS_LABELS via t()
- CreateThreadModal: replace title, thread name label, category label, placeholder, cancel/submit buttons, error messages
- AddToThreadModal: replace modal titles, labels, placeholders, back/cancel/submit buttons, error messages
- threads.json: extend candidateForm with category, notes, pros, cons, product link labels and all placeholders
This commit is contained in:
2026-04-18 13:44:26 +02:00
parent c5af1247c0
commit 6fd8874970
8 changed files with 158 additions and 84 deletions

View File

@@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
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 { useGlobalItem } from "../hooks/useGlobalItems"; import { useGlobalItem } from "../hooks/useGlobalItems";
@@ -8,6 +9,7 @@ import { apiPost } from "../lib/api";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
export function AddToThreadModal() { export function AddToThreadModal() {
const { t } = useTranslation(["threads", "common"]);
const { open, globalItemId, globalItemName } = useUIStore( const { open, globalItemId, globalItemName } = useUIStore(
(s) => s.addToThreadModal, (s) => s.addToThreadModal,
); );
@@ -114,7 +116,7 @@ export function AddToThreadModal() {
toast.success(`Added to "${thread?.name ?? "thread"}"`); toast.success(`Added to "${thread?.name ?? "thread"}"`);
closeAddToThread(); closeAddToThread();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to add candidate"); setError(err instanceof Error ? err.message : t("addToThread.failedToAdd"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -142,7 +144,7 @@ export function AddToThreadModal() {
toast.success(`Created "${trimmedName}" with first candidate`); toast.success(`Created "${trimmedName}" with first candidate`);
closeAddToThread(); closeAddToThread();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to create thread"); setError(err instanceof Error ? err.message : t("addToThread.failedToCreate"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -173,7 +175,7 @@ export function AddToThreadModal() {
onKeyDown={() => {}} onKeyDown={() => {}}
> >
<h2 className="text-lg font-semibold text-gray-900 mb-1"> <h2 className="text-lg font-semibold text-gray-900 mb-1">
{mode === "pick" ? "Add to Thread" : "New Thread + Candidate"} {mode === "pick" ? t("addToThread.title") : t("addToThread.newThreadTitle")}
</h2> </h2>
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p> <p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
@@ -184,7 +186,7 @@ export function AddToThreadModal() {
htmlFor="thread-select" htmlFor="thread-select"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Thread {t("addToThread.thread")}
</label> </label>
<select <select
id="thread-select" id="thread-select"
@@ -197,7 +199,7 @@ export function AddToThreadModal() {
{t.name} ({t.categoryName}) {t.name} ({t.categoryName})
</option> </option>
))} ))}
<option value="new">+ New Thread...</option> <option value="new">{t("addToThread.newThread")}</option>
</select> </select>
</div> </div>
) : ( ) : (
@@ -207,14 +209,14 @@ export function AddToThreadModal() {
htmlFor="new-thread-name" htmlFor="new-thread-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Thread name {t("addToThread.threadName")}
</label> </label>
<input <input
id="new-thread-name" id="new-thread-name"
type="text" type="text"
value={newThreadName} value={newThreadName}
onChange={(e) => setNewThreadName(e.target.value)} onChange={(e) => setNewThreadName(e.target.value)}
placeholder="e.g. Lightweight sleeping bag" placeholder={t("create.namePlaceholder")}
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"
/> />
</div> </div>
@@ -224,7 +226,7 @@ export function AddToThreadModal() {
htmlFor="new-thread-category" htmlFor="new-thread-category"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Category {t("create.category")}
</label> </label>
<select <select
id="new-thread-category" id="new-thread-category"
@@ -248,7 +250,7 @@ export function AddToThreadModal() {
onClick={() => setMode("pick")} onClick={() => setMode("pick")}
className="text-sm text-gray-500 hover:text-gray-700 underline" className="text-sm text-gray-500 hover:text-gray-700 underline"
> >
Back to thread picker {t("addToThread.backToPicker")}
</button> </button>
)} )}
</> </>
@@ -266,7 +268,7 @@ export function AddToThreadModal() {
} }
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"
@@ -274,10 +276,10 @@ export function AddToThreadModal() {
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"
> >
{isSubmitting {isSubmitting
? "Adding..." ? t("addToThread.adding")
: mode === "pick" : mode === "pick"
? "Add as Candidate" ? t("addToThread.addAsCandidate")
: "Create & Add"} : t("addToThread.createAndAdd")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas"; import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
@@ -55,6 +56,7 @@ export function CandidateCard({
rank, rank,
delta, delta,
}: CandidateCardProps) { }: CandidateCardProps) {
const { t } = useTranslation("threads");
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
const openConfirmDeleteCandidate = useUIStore( const openConfirmDeleteCandidate = useUIStore(
@@ -90,10 +92,10 @@ export function CandidateCard({
} }
}} }}
className="absolute top-2 left-2 z-10 px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-all cursor-pointer" className="absolute top-2 left-2 z-10 px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Pick as winner" title={t("candidateCard.pickAsWinner")}
> >
<LucideIcon name="trophy" size={12} /> <LucideIcon name="trophy" size={12} />
Winner {t("candidateCard.winner")}
</span> </span>
)} )}
<span <span
@@ -110,7 +112,7 @@ export function CandidateCard({
} }
}} }}
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`} className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Delete candidate" title={t("candidateCard.deleteCandidate")}
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -141,7 +143,7 @@ export function CandidateCard({
} }
}} }}
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer" className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Open product link" title={t("candidateCard.openProductLink")}
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -214,7 +216,7 @@ export function CandidateCard({
<StatusBadge status={status} onStatusChange={onStatusChange} /> <StatusBadge status={status} onStatusChange={onStatusChange} />
{(pros || cons) && ( {(pros || cons) && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
+/- Notes {t("candidateCard.prosCons")}
</span> </span>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates"; import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
import { useCurrency } from "../hooks/useCurrency"; import { useCurrency } from "../hooks/useCurrency";
import { useThread } from "../hooks/useThreads"; import { useThread } from "../hooks/useThreads";
@@ -42,6 +43,7 @@ export function CandidateForm({
candidateId, candidateId,
onClose, onClose,
}: CandidateFormProps) { }: CandidateFormProps) {
const { t } = useTranslation(["threads", "common"]);
const { data: thread } = useThread(threadId); const { data: thread } = useThread(threadId);
const { currency } = useCurrency(); const { currency } = useCurrency();
const createCandidate = useCreateCandidate(threadId); const createCandidate = useCreateCandidate(threadId);
@@ -79,26 +81,26 @@ export function CandidateForm({
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;
@@ -160,7 +162,7 @@ export function CandidateForm({
htmlFor="candidate-name" htmlFor="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("candidateForm.nameRequired")}
</label> </label>
<input <input
id="candidate-name" id="candidate-name"
@@ -168,7 +170,7 @@ export function CandidateForm({
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("candidateForm.namePlaceholder")}
/> />
{errors.name && ( {errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p> <p className="mt-1 text-xs text-red-500">{errors.name}</p>
@@ -181,7 +183,7 @@ export function CandidateForm({
htmlFor="candidate-weight" htmlFor="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("candidateForm.weightLabel")}
</label> </label>
<input <input
id="candidate-weight" id="candidate-weight"
@@ -193,7 +195,7 @@ export function CandidateForm({
setForm((f) => ({ ...f, weightGrams: e.target.value })) setForm((f) => ({ ...f, weightGrams: 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. 680" placeholder={t("candidateForm.weightPlaceholder")}
/> />
{errors.weightGrams && ( {errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p> <p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
@@ -218,7 +220,7 @@ export function CandidateForm({
setForm((f) => ({ ...f, priceDollars: e.target.value })) setForm((f) => ({ ...f, priceDollars: 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. 129.99" placeholder={t("candidateForm.pricePlaceholder")}
/> />
{errors.priceDollars && ( {errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p> <p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
@@ -228,7 +230,7 @@ export function CandidateForm({
{/* 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("candidateForm.categoryLabel")}
</label> </label>
<CategoryPicker <CategoryPicker
value={form.categoryId} value={form.categoryId}
@@ -242,7 +244,7 @@ export function CandidateForm({
htmlFor="candidate-notes" htmlFor="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("candidateForm.notesLabel")}
</label> </label>
<textarea <textarea
id="candidate-notes" id="candidate-notes"
@@ -250,7 +252,7 @@ export function CandidateForm({
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, notes: 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="Any additional notes..." placeholder={t("candidateForm.notesPlaceholder")}
/> />
</div> </div>
@@ -260,7 +262,7 @@ export function CandidateForm({
htmlFor="candidate-pros" htmlFor="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("candidateForm.prosLabel")}
</label> </label>
<textarea <textarea
id="candidate-pros" id="candidate-pros"
@@ -268,7 +270,7 @@ export function CandidateForm({
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("candidateForm.prosPlaceholder")}
/> />
</div> </div>
@@ -278,7 +280,7 @@ export function CandidateForm({
htmlFor="candidate-cons" htmlFor="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("candidateForm.consLabel")}
</label> </label>
<textarea <textarea
id="candidate-cons" id="candidate-cons"
@@ -286,7 +288,7 @@ export function CandidateForm({
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("candidateForm.consPlaceholder")}
/> />
</div> </div>
@@ -296,7 +298,7 @@ export function CandidateForm({
htmlFor="candidate-url" htmlFor="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("candidateForm.productLinkLabel")}
</label> </label>
<input <input
id="candidate-url" id="candidate-url"
@@ -306,7 +308,7 @@ export function CandidateForm({
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("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>
@@ -321,10 +323,10 @@ export function CandidateForm({
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"
> >
{isPending {isPending
? "Saving..." ? t("common:actions.saving")
: mode === "add" : mode === "add"
? "Add Candidate" ? t("candidateForm.addCandidate")
: "Save Changes"} : t("candidateForm.saveChanges")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,6 +1,7 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Reorder } from "framer-motion"; import { Reorder } from "framer-motion";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas"; import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
@@ -61,6 +62,7 @@ export function CandidateListItem({
delta, delta,
onDragEnd, onDragEnd,
}: CandidateListItemProps) { }: CandidateListItemProps) {
const { t } = useTranslation("threads");
const isDragging = useRef(false); const isDragging = useRef(false);
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -150,7 +152,7 @@ export function CandidateListItem({
/> />
{(candidate.pros || candidate.cons) && ( {(candidate.pros || candidate.cons) && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
+/- Notes {t("candidateCard.prosCons")}
</span> </span>
)} )}
</div> </div>
@@ -166,10 +168,10 @@ export function CandidateListItem({
openResolveDialog(candidate.threadId, candidate.id); openResolveDialog(candidate.threadId, candidate.id);
}} }}
className="px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 cursor-pointer" className="px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 cursor-pointer"
title="Pick as winner" title={t("candidateCard.pickAsWinner")}
> >
<LucideIcon name="trophy" size={12} /> <LucideIcon name="trophy" size={12} />
Winner {t("candidateCard.winner")}
</button> </button>
)} )}
{candidate.productUrl && ( {candidate.productUrl && (
@@ -180,7 +182,7 @@ export function CandidateListItem({
openExternalLink(candidate.productUrl as string); openExternalLink(candidate.productUrl as string);
}} }}
className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer" className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer"
title="Open product link" title={t("candidateCard.openProductLink")}
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -204,7 +206,7 @@ export function CandidateListItem({
openConfirmDeleteCandidate(candidate.id); openConfirmDeleteCandidate(candidate.id);
}} }}
className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 cursor-pointer" className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 cursor-pointer"
title="Delete candidate" title={t("candidateCard.deleteCandidate")}
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas"; import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
@@ -33,17 +34,17 @@ interface ComparisonTableProps {
deltas?: Record<number, CandidateDelta>; deltas?: Record<number, CandidateDelta>;
} }
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
researching: "Researching",
ordered: "Ordered",
arrived: "Arrived",
};
export function ComparisonTable({ export function ComparisonTable({
candidates, candidates,
resolvedCandidateId, resolvedCandidateId,
deltas, deltas,
}: ComparisonTableProps) { }: ComparisonTableProps) {
const { t } = useTranslation("threads");
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
researching: t("statusBadge.researching"),
ordered: t("statusBadge.ordered"),
arrived: t("statusBadge.arrived"),
};
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const openExternalLink = useUIStore((s) => s.openExternalLink); const openExternalLink = useUIStore((s) => s.openExternalLink);
@@ -113,7 +114,7 @@ export function ComparisonTable({
}> = [ }> = [
{ {
key: "image", key: "image",
label: "Image", label: t("comparisonTable.image"),
render: (c) => ( render: (c) => (
<div <div
className="w-12 h-12 rounded-lg overflow-hidden flex items-center justify-center" className="w-12 h-12 rounded-lg overflow-hidden flex items-center justify-center"
@@ -138,19 +139,19 @@ export function ComparisonTable({
}, },
{ {
key: "name", key: "name",
label: "Name", label: t("comparisonTable.name"),
render: (c) => ( render: (c) => (
<span className="text-sm font-medium text-gray-900">{c.name}</span> <span className="text-sm font-medium text-gray-900">{c.name}</span>
), ),
}, },
{ {
key: "rank", key: "rank",
label: "Rank", label: t("comparisonTable.rank"),
render: (_c, index) => <RankBadge rank={index + 1} />, render: (_c, index) => <RankBadge rank={index + 1} />,
}, },
{ {
key: "weight", key: "weight",
label: "Weight", label: t("comparisonTable.weight"),
render: (c) => { render: (c) => {
const isBest = c.id === bestWeightId; const isBest = c.id === bestWeightId;
const delta = weightDeltas[c.id]; const delta = weightDeltas[c.id];
@@ -176,7 +177,7 @@ export function ComparisonTable({
}, },
{ {
key: "price", key: "price",
label: "Price", label: t("comparisonTable.price"),
render: (c) => { render: (c) => {
const isBest = c.id === bestPriceId; const isBest = c.id === bestPriceId;
const delta = priceDeltas[c.id]; const delta = priceDeltas[c.id];
@@ -202,14 +203,14 @@ export function ComparisonTable({
}, },
{ {
key: "status", key: "status",
label: "Status", label: t("comparisonTable.status"),
render: (c) => ( render: (c) => (
<span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span> <span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span>
), ),
}, },
{ {
key: "link", key: "link",
label: "Link", label: t("comparisonTable.link"),
render: (c) => render: (c) =>
c.productUrl ? ( c.productUrl ? (
<button <button
@@ -217,7 +218,7 @@ export function ComparisonTable({
onClick={() => openExternalLink(c.productUrl as string)} onClick={() => openExternalLink(c.productUrl as string)}
className="text-xs text-blue-500 hover:underline" className="text-xs text-blue-500 hover:underline"
> >
View {t("comparisonTable.view")}
</button> </button>
) : ( ) : (
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
@@ -225,7 +226,7 @@ export function ComparisonTable({
}, },
{ {
key: "notes", key: "notes",
label: "Notes", label: t("comparisonTable.notes"),
render: (c) => render: (c) =>
c.notes ? ( c.notes ? (
<p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p> <p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p>
@@ -235,7 +236,7 @@ export function ComparisonTable({
}, },
{ {
key: "pros", key: "pros",
label: "Pros", label: t("comparisonTable.pros"),
render: (c) => { render: (c) => {
if (!c.pros) return <span className="text-gray-300"></span>; if (!c.pros) return <span className="text-gray-300"></span>;
const items = c.pros.split("\n").filter((s) => s.trim() !== ""); const items = c.pros.split("\n").filter((s) => s.trim() !== "");
@@ -254,7 +255,7 @@ export function ComparisonTable({
}, },
{ {
key: "cons", key: "cons",
label: "Cons", label: t("comparisonTable.cons"),
render: (c) => { render: (c) => {
if (!c.cons) return <span className="text-gray-300"></span>; if (!c.cons) return <span className="text-gray-300"></span>;
const items = c.cons.split("\n").filter((s) => s.trim() !== ""); const items = c.cons.split("\n").filter((s) => s.trim() !== "");
@@ -342,7 +343,7 @@ export function ComparisonTable({
<> <>
<tr className="border-b border-gray-50"> <tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28"> <td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Weight Impact {t("comparisonTable.weightImpact")}
</td> </td>
{candidates.map((candidate) => { {candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId; const isWinner = candidate.id === resolvedCandidateId;
@@ -362,7 +363,7 @@ export function ComparisonTable({
</tr> </tr>
<tr className="border-b border-gray-50"> <tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28"> <td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Price Impact {t("comparisonTable.priceImpact")}
</td> </td>
{candidates.map((candidate) => { {candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId; const isWinner = candidate.id === resolvedCandidateId;

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCategories } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import { useCreateThread } from "../hooks/useThreads"; import { useCreateThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
export function CreateThreadModal() { export function CreateThreadModal() {
const { t } = useTranslation(["threads", "common"]);
const isOpen = useUIStore((s) => s.createThreadModalOpen); const isOpen = useUIStore((s) => s.createThreadModalOpen);
const closeModal = useUIStore((s) => s.closeCreateThreadModal); const closeModal = useUIStore((s) => s.closeCreateThreadModal);
@@ -38,11 +40,11 @@ export function CreateThreadModal() {
e.preventDefault(); e.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
setError("Thread name is required"); setError(t("create.nameRequired"));
return; return;
} }
if (categoryId === null) { if (categoryId === null) {
setError("Please select a category"); setError(t("create.selectCategory"));
return; return;
} }
setError(null); setError(null);
@@ -55,7 +57,7 @@ export function CreateThreadModal() {
}, },
onError: (err) => { onError: (err) => {
setError( setError(
err instanceof Error ? err.message : "Failed to create thread", err instanceof Error ? err.message : t("create.createFailed"),
); );
}, },
}, },
@@ -77,7 +79,7 @@ export function CreateThreadModal() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={() => {}} onKeyDown={() => {}}
> >
<h2 className="text-lg font-semibold text-gray-900 mb-4">New Thread</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">{t("create.title")}</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -85,14 +87,14 @@ export function CreateThreadModal() {
htmlFor="thread-name" htmlFor="thread-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Thread name {t("create.threadName")}
</label> </label>
<input <input
id="thread-name" id="thread-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Lightweight sleeping bag" placeholder={t("create.namePlaceholder")}
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"
/> />
</div> </div>
@@ -102,7 +104,7 @@ export function CreateThreadModal() {
htmlFor="thread-category" htmlFor="thread-category"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Category {t("create.category")}
</label> </label>
<select <select
id="thread-category" id="thread-category"
@@ -126,14 +128,14 @@ export function CreateThreadModal() {
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={createThread.isPending} disabled={createThread.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"
> >
{createThread.isPending ? "Creating..." : "Create Thread"} {createThread.isPending ? t("common:actions.creating") : t("create.createThread")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
const STATUS_CONFIG = { const STATUS_ICONS = {
researching: { icon: "search", label: "Researching" }, researching: "search",
ordered: { icon: "truck", label: "Ordered" }, ordered: "truck",
arrived: { icon: "check", label: "Arrived" }, arrived: "check",
} as const; } as const;
type CandidateStatus = keyof typeof STATUS_CONFIG; type CandidateStatus = keyof typeof STATUS_ICONS;
interface StatusBadgeProps { interface StatusBadgeProps {
status: CandidateStatus; status: CandidateStatus;
@@ -15,10 +16,15 @@ interface StatusBadgeProps {
} }
export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) { export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
const { t } = useTranslation("threads");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const config = STATUS_CONFIG[status]; const STATUS_LABELS: Record<CandidateStatus, string> = {
researching: t("statusBadge.researching"),
ordered: t("statusBadge.ordered"),
arrived: t("statusBadge.arrived"),
};
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -56,14 +62,13 @@ export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
> >
<LucideIcon name={config.icon} size={14} className="text-gray-500" /> <LucideIcon name={STATUS_ICONS[status]} size={14} className="text-gray-500" />
{config.label} {STATUS_LABELS[status]}
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute right-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[150px]"> <div className="absolute right-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[150px]">
{(Object.keys(STATUS_CONFIG) as CandidateStatus[]).map((key) => { {(Object.keys(STATUS_ICONS) as CandidateStatus[]).map((key) => {
const option = STATUS_CONFIG[key];
const isActive = key === status; const isActive = key === status;
return ( return (
<button <button
@@ -79,12 +84,12 @@ export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
}`} }`}
> >
<LucideIcon <LucideIcon
name={option.icon} name={STATUS_ICONS[key]}
size={14} size={14}
className={isActive ? "text-gray-700" : "text-gray-400"} className={isActive ? "text-gray-700" : "text-gray-400"}
/> />
<span className={isActive ? "text-gray-900" : "text-gray-600"}> <span className={isActive ? "text-gray-900" : "text-gray-600"}>
{option.label} {STATUS_LABELS[key]}
</span> </span>
{isActive && ( {isActive && (
<LucideIcon <LucideIcon

View File

@@ -46,6 +46,64 @@
"candidates": "{{count}} candidates", "candidates": "{{count}} candidates",
"candidates_one": "{{count}} candidate" "candidates_one": "{{count}} candidate"
}, },
"candidateCard": {
"pickAsWinner": "Pick as winner",
"winner": "Winner",
"deleteCandidate": "Delete candidate",
"openProductLink": "Open product link",
"prosCons": "+/- Notes"
},
"candidateForm": {
"nameRequired": "Name *",
"weightLabel": "Weight (g)",
"categoryLabel": "Category",
"notesLabel": "Notes",
"prosLabel": "Pros",
"consLabel": "Cons",
"productLinkLabel": "Product Link",
"namePlaceholder": "e.g. Osprey Talon 22",
"weightPlaceholder": "e.g. 680",
"pricePlaceholder": "e.g. 129.99",
"notesPlaceholder": "Any additional notes...",
"prosPlaceholder": "One pro per line...",
"consPlaceholder": "One con per line...",
"urlPlaceholder": "https://...",
"addCandidate": "Add Candidate",
"saveChanges": "Save Changes"
},
"comparisonTable": {
"image": "Image",
"name": "Name",
"rank": "Rank",
"weight": "Weight",
"price": "Price",
"status": "Status",
"link": "Link",
"notes": "Notes",
"pros": "Pros",
"cons": "Cons",
"weightImpact": "Weight Impact",
"priceImpact": "Price Impact",
"view": "View"
},
"addToThread": {
"title": "Add to Thread",
"newThreadTitle": "New Thread + Candidate",
"thread": "Thread",
"threadName": "Thread name",
"newThread": "+ New Thread...",
"backToPicker": "Back to thread picker",
"addAsCandidate": "Add as Candidate",
"createAndAdd": "Create & Add",
"adding": "Adding...",
"failedToAdd": "Failed to add candidate",
"failedToCreate": "Failed to create thread"
},
"statusBadge": {
"researching": "Researching",
"ordered": "Ordered",
"arrived": "Arrived"
},
"planning": { "planning": {
"title": "Planning Threads", "title": "Planning Threads",
"emptyTitle": "Plan your next purchase", "emptyTitle": "Plan your next purchase",