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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user