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