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

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
@@ -55,6 +56,7 @@ export function CandidateCard({
rank,
delta,
}: CandidateCardProps) {
const { t } = useTranslation("threads");
const { weight, price } = useFormatters();
const navigate = useNavigate();
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"
title="Pick as winner"
title={t("candidateCard.pickAsWinner")}
>
<LucideIcon name="trophy" size={12} />
Winner
{t("candidateCard.winner")}
</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`}
title="Delete candidate"
title={t("candidateCard.deleteCandidate")}
>
<svg
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"
title="Open product link"
title={t("candidateCard.openProductLink")}
>
<svg
className="w-3.5 h-3.5"
@@ -214,7 +216,7 @@ export function CandidateCard({
<StatusBadge status={status} onStatusChange={onStatusChange} />
{(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">
+/- Notes
{t("candidateCard.prosCons")}
</span>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
import { useCurrency } from "../hooks/useCurrency";
import { useThread } from "../hooks/useThreads";
@@ -42,6 +43,7 @@ export function CandidateForm({
candidateId,
onClose,
}: CandidateFormProps) {
const { t } = useTranslation(["threads", "common"]);
const { data: thread } = useThread(threadId);
const { currency } = useCurrency();
const createCandidate = useCreateCandidate(threadId);
@@ -79,26 +81,26 @@ export function CandidateForm({
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
newErrors.name = t("common:errors.nameRequired");
}
if (
form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number";
newErrors.weightGrams = t("common:errors.positiveNumber");
}
if (
form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number";
newErrors.priceDollars = t("common:errors.positiveNumber");
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
newErrors.productUrl = t("common:errors.validUrl");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -160,7 +162,7 @@ export function CandidateForm({
htmlFor="candidate-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
{t("candidateForm.nameRequired")}
</label>
<input
id="candidate-name"
@@ -168,7 +170,7 @@ export function CandidateForm({
value={form.name}
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"
placeholder="e.g. Osprey Talon 22"
placeholder={t("candidateForm.namePlaceholder")}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
@@ -181,7 +183,7 @@ export function CandidateForm({
htmlFor="candidate-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
{t("candidateForm.weightLabel")}
</label>
<input
id="candidate-weight"
@@ -193,7 +195,7 @@ export function CandidateForm({
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"
placeholder="e.g. 680"
placeholder={t("candidateForm.weightPlaceholder")}
/>
{errors.weightGrams && (
<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 }))
}
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 && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
@@ -228,7 +230,7 @@ export function CandidateForm({
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
{t("candidateForm.categoryLabel")}
</label>
<CategoryPicker
value={form.categoryId}
@@ -242,7 +244,7 @@ export function CandidateForm({
htmlFor="candidate-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
{t("candidateForm.notesLabel")}
</label>
<textarea
id="candidate-notes"
@@ -250,7 +252,7 @@ export function CandidateForm({
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
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"
placeholder="Any additional notes..."
placeholder={t("candidateForm.notesPlaceholder")}
/>
</div>
@@ -260,7 +262,7 @@ export function CandidateForm({
htmlFor="candidate-pros"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pros
{t("candidateForm.prosLabel")}
</label>
<textarea
id="candidate-pros"
@@ -268,7 +270,7 @@ export function CandidateForm({
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
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"
placeholder="One pro per line..."
placeholder={t("candidateForm.prosPlaceholder")}
/>
</div>
@@ -278,7 +280,7 @@ export function CandidateForm({
htmlFor="candidate-cons"
className="block text-sm font-medium text-gray-700 mb-1"
>
Cons
{t("candidateForm.consLabel")}
</label>
<textarea
id="candidate-cons"
@@ -286,7 +288,7 @@ export function CandidateForm({
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
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"
placeholder="One con per line..."
placeholder={t("candidateForm.consPlaceholder")}
/>
</div>
@@ -296,7 +298,7 @@ export function CandidateForm({
htmlFor="candidate-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
{t("candidateForm.productLinkLabel")}
</label>
<input
id="candidate-url"
@@ -306,7 +308,7 @@ export function CandidateForm({
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"
placeholder="https://..."
placeholder={t("candidateForm.urlPlaceholder")}
/>
{errors.productUrl && (
<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"
>
{isPending
? "Saving..."
? t("common:actions.saving")
: mode === "add"
? "Add Candidate"
: "Save Changes"}
? t("candidateForm.addCandidate")
: t("candidateForm.saveChanges")}
</button>
</div>
</form>

View File

@@ -1,6 +1,7 @@
import { useNavigate } from "@tanstack/react-router";
import { Reorder } from "framer-motion";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
@@ -61,6 +62,7 @@ export function CandidateListItem({
delta,
onDragEnd,
}: CandidateListItemProps) {
const { t } = useTranslation("threads");
const isDragging = useRef(false);
const { weight, price } = useFormatters();
const navigate = useNavigate();
@@ -150,7 +152,7 @@ export function CandidateListItem({
/>
{(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">
+/- Notes
{t("candidateCard.prosCons")}
</span>
)}
</div>
@@ -166,10 +168,10 @@ export function CandidateListItem({
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"
title="Pick as winner"
title={t("candidateCard.pickAsWinner")}
>
<LucideIcon name="trophy" size={12} />
Winner
{t("candidateCard.winner")}
</button>
)}
{candidate.productUrl && (
@@ -180,7 +182,7 @@ export function CandidateListItem({
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"
title="Open product link"
title={t("candidateCard.openProductLink")}
>
<svg
className="w-3.5 h-3.5"
@@ -204,7 +206,7 @@ export function CandidateListItem({
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"
title="Delete candidate"
title={t("candidateCard.deleteCandidate")}
>
<svg
className="w-3.5 h-3.5"

View File

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

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCategories } from "../hooks/useCategories";
import { useCreateThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
export function CreateThreadModal() {
const { t } = useTranslation(["threads", "common"]);
const isOpen = useUIStore((s) => s.createThreadModalOpen);
const closeModal = useUIStore((s) => s.closeCreateThreadModal);
@@ -38,11 +40,11 @@ export function CreateThreadModal() {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
setError("Thread name is required");
setError(t("create.nameRequired"));
return;
}
if (categoryId === null) {
setError("Please select a category");
setError(t("create.selectCategory"));
return;
}
setError(null);
@@ -55,7 +57,7 @@ export function CreateThreadModal() {
},
onError: (err) => {
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()}
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">
<div>
@@ -85,14 +87,14 @@ export function CreateThreadModal() {
htmlFor="thread-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Thread name
{t("create.threadName")}
</label>
<input
id="thread-name"
type="text"
value={name}
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"
/>
</div>
@@ -102,7 +104,7 @@ export function CreateThreadModal() {
htmlFor="thread-category"
className="block text-sm font-medium text-gray-700 mb-1"
>
Category
{t("create.category")}
</label>
<select
id="thread-category"
@@ -126,14 +128,14 @@ export function CreateThreadModal() {
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"
>
Cancel
{t("common:actions.cancel")}
</button>
<button
type="submit"
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"
>
{createThread.isPending ? "Creating..." : "Create Thread"}
{createThread.isPending ? t("common:actions.creating") : t("create.createThread")}
</button>
</div>
</form>

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LucideIcon } from "../lib/iconData";
const STATUS_CONFIG = {
researching: { icon: "search", label: "Researching" },
ordered: { icon: "truck", label: "Ordered" },
arrived: { icon: "check", label: "Arrived" },
const STATUS_ICONS = {
researching: "search",
ordered: "truck",
arrived: "check",
} as const;
type CandidateStatus = keyof typeof STATUS_CONFIG;
type CandidateStatus = keyof typeof STATUS_ICONS;
interface StatusBadgeProps {
status: CandidateStatus;
@@ -15,10 +16,15 @@ interface StatusBadgeProps {
}
export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
const { t } = useTranslation("threads");
const [isOpen, setIsOpen] = useState(false);
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(() => {
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"
>
<LucideIcon name={config.icon} size={14} className="text-gray-500" />
{config.label}
<LucideIcon name={STATUS_ICONS[status]} size={14} className="text-gray-500" />
{STATUS_LABELS[status]}
</button>
{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]">
{(Object.keys(STATUS_CONFIG) as CandidateStatus[]).map((key) => {
const option = STATUS_CONFIG[key];
{(Object.keys(STATUS_ICONS) as CandidateStatus[]).map((key) => {
const isActive = key === status;
return (
<button
@@ -79,12 +84,12 @@ export function StatusBadge({ status, onStatusChange }: StatusBadgeProps) {
}`}
>
<LucideIcon
name={option.icon}
name={STATUS_ICONS[key]}
size={14}
className={isActive ? "text-gray-700" : "text-gray-400"}
/>
<span className={isActive ? "text-gray-900" : "text-gray-600"}>
{option.label}
{STATUS_LABELS[key]}
</span>
{isActive && (
<LucideIcon

View File

@@ -46,6 +46,64 @@
"candidates": "{{count}} candidates",
"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": {
"title": "Planning Threads",
"emptyTitle": "Plan your next purchase",