Replaced hardcoded "Price ($)" labels across 6 components and 2 locale files to display the user's selected currency (EUR, GBP, USD, etc.). AddToCollectionModal also updated to show correct currency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { Reorder } from "framer-motion";
|
|
import { useEffect, useState } from "react";
|
|
import { CandidateCard } from "../../../components/CandidateCard";
|
|
import { CandidateListItem } from "../../../components/CandidateListItem";
|
|
import { CategoryPicker } from "../../../components/CategoryPicker";
|
|
import { ComparisonTable } from "../../../components/ComparisonTable";
|
|
import { ImageUpload } from "../../../components/ImageUpload";
|
|
import { SetupImpactSelector } from "../../../components/SetupImpactSelector";
|
|
import {
|
|
useCreateCandidate,
|
|
useReorderCandidates,
|
|
useUpdateCandidate,
|
|
} from "../../../hooks/useCandidates";
|
|
import { useCurrency } from "../../../hooks/useCurrency";
|
|
import { useImpactDeltas } from "../../../hooks/useImpactDeltas";
|
|
import { useSetup } from "../../../hooks/useSetups";
|
|
import { useThread } from "../../../hooks/useThreads";
|
|
import { LucideIcon } from "../../../lib/iconData";
|
|
import { useUIStore } from "../../../stores/uiStore";
|
|
|
|
export const Route = createFileRoute("/threads/$threadId/")({
|
|
component: ThreadDetailPage,
|
|
});
|
|
|
|
function ThreadDetailPage() {
|
|
const { threadId: threadIdParam } = Route.useParams();
|
|
const threadId = Number(threadIdParam);
|
|
const { data: thread, isLoading, isError } = useThread(threadId);
|
|
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
|
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
|
const updateCandidate = useUpdateCandidate(threadId);
|
|
const reorderMutation = useReorderCandidates(threadId);
|
|
const { data: setupData } = useSetup(selectedSetupId);
|
|
const { deltas } = useImpactDeltas(
|
|
thread?.candidates ?? [],
|
|
setupData?.items,
|
|
thread?.categoryId ?? 0,
|
|
);
|
|
|
|
const [addCandidateOpen, setAddCandidateOpen] = useState(false);
|
|
|
|
const [tempItems, setTempItems] = useState<
|
|
NonNullable<typeof thread>["candidates"] | null
|
|
>(null);
|
|
|
|
// Clear tempItems when server data changes
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
|
useEffect(() => {
|
|
setTempItems(null);
|
|
}, [thread?.candidates]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div className="animate-pulse space-y-6">
|
|
<div className="h-6 bg-gray-200 rounded w-48" />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !thread) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
Thread not found
|
|
</h2>
|
|
<Link
|
|
to="/"
|
|
search={{ tab: "planning" }}
|
|
className="text-sm text-gray-600 hover:text-gray-700"
|
|
>
|
|
Back to planning
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isActive = thread.status === "active";
|
|
const winningCandidate = thread.resolvedCandidateId
|
|
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
|
: null;
|
|
|
|
const displayItems = tempItems ?? thread.candidates;
|
|
|
|
function handleDragEnd() {
|
|
if (!tempItems) return;
|
|
reorderMutation.mutate({
|
|
orderedIds: tempItems.map((c) => c.id),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<Link
|
|
to="/"
|
|
search={{ tab: "planning" }}
|
|
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
|
>
|
|
← Back to planning
|
|
</Link>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
isActive
|
|
? "bg-gray-100 text-gray-600"
|
|
: "bg-gray-100 text-gray-500"
|
|
}`}
|
|
>
|
|
{isActive ? "Active" : "Resolved"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resolution banner */}
|
|
{!isActive && winningCandidate && (
|
|
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
|
<p className="text-sm text-amber-800">
|
|
<span className="font-medium">{winningCandidate.name}</span> was
|
|
picked as the winner and added to your collection.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toolbar: Add candidate + view toggle */}
|
|
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
|
{isActive && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setAddCandidateOpen(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
Add Candidate
|
|
</button>
|
|
)}
|
|
{thread.candidates.length > 0 && (
|
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCandidateViewMode("list")}
|
|
className={`p-1.5 rounded-md transition-colors ${
|
|
candidateViewMode === "list"
|
|
? "bg-gray-200 text-gray-900"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
title="List view"
|
|
>
|
|
<LucideIcon name="layout-list" size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCandidateViewMode("grid")}
|
|
className={`p-1.5 rounded-md transition-colors ${
|
|
candidateViewMode === "grid"
|
|
? "bg-gray-200 text-gray-900"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
title="Grid view"
|
|
>
|
|
<LucideIcon name="layout-grid" size={16} />
|
|
</button>
|
|
{thread.candidates.length >= 2 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setCandidateViewMode("compare")}
|
|
className={`p-1.5 rounded-md transition-colors ${
|
|
candidateViewMode === "compare"
|
|
? "bg-gray-200 text-gray-900"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
title="Compare view"
|
|
>
|
|
<LucideIcon name="columns-3" size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<SetupImpactSelector threadStatus={thread.status} />
|
|
</div>
|
|
|
|
{/* Candidates */}
|
|
{thread.candidates.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<div className="mb-3">
|
|
<LucideIcon
|
|
name="tag"
|
|
size={48}
|
|
className="text-gray-400 mx-auto"
|
|
/>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
|
No candidates yet
|
|
</h3>
|
|
<p className="text-sm text-gray-500">
|
|
Add your first candidate to start comparing.
|
|
</p>
|
|
</div>
|
|
) : candidateViewMode === "compare" ? (
|
|
<ComparisonTable
|
|
candidates={displayItems}
|
|
resolvedCandidateId={thread.resolvedCandidateId}
|
|
deltas={deltas}
|
|
/>
|
|
) : candidateViewMode === "list" ? (
|
|
isActive ? (
|
|
<Reorder.Group
|
|
axis="y"
|
|
values={displayItems}
|
|
onReorder={setTempItems}
|
|
className="flex flex-col"
|
|
>
|
|
{displayItems.map((candidate, index) => (
|
|
<CandidateListItem
|
|
key={candidate.id}
|
|
candidate={candidate}
|
|
rank={index + 1}
|
|
isActive={isActive}
|
|
onStatusChange={(newStatus) =>
|
|
updateCandidate.mutate({
|
|
candidateId: candidate.id,
|
|
status: newStatus,
|
|
})
|
|
}
|
|
delta={deltas[candidate.id]}
|
|
onDragEnd={handleDragEnd}
|
|
/>
|
|
))}
|
|
</Reorder.Group>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{displayItems.map((candidate, index) => (
|
|
<CandidateListItem
|
|
key={candidate.id}
|
|
candidate={candidate}
|
|
rank={index + 1}
|
|
isActive={isActive}
|
|
onStatusChange={(newStatus) =>
|
|
updateCandidate.mutate({
|
|
candidateId: candidate.id,
|
|
status: newStatus,
|
|
})
|
|
}
|
|
delta={deltas[candidate.id]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{thread.candidates.map((candidate, index) => (
|
|
<CandidateCard
|
|
key={candidate.id}
|
|
id={candidate.id}
|
|
name={candidate.name}
|
|
weightGrams={candidate.weightGrams}
|
|
priceCents={candidate.priceCents}
|
|
categoryName={candidate.categoryName}
|
|
categoryIcon={candidate.categoryIcon}
|
|
imageFilename={candidate.imageFilename}
|
|
imageUrl={candidate.imageUrl}
|
|
productUrl={candidate.productUrl}
|
|
threadId={threadId}
|
|
isActive={isActive}
|
|
status={candidate.status}
|
|
onStatusChange={(newStatus) =>
|
|
updateCandidate.mutate({
|
|
candidateId: candidate.id,
|
|
status: newStatus,
|
|
})
|
|
}
|
|
pros={candidate.pros}
|
|
cons={candidate.cons}
|
|
rank={index + 1}
|
|
delta={deltas[candidate.id]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{addCandidateOpen && (
|
|
<AddCandidateModal
|
|
threadId={threadId}
|
|
onClose={() => setAddCandidateOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AddCandidateModalProps {
|
|
threadId: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface ModalFormData {
|
|
name: string;
|
|
weightGrams: string;
|
|
priceDollars: string;
|
|
categoryId: number;
|
|
notes: string;
|
|
productUrl: string;
|
|
imageFilename: string | null;
|
|
pros: string;
|
|
cons: string;
|
|
}
|
|
|
|
const INITIAL_MODAL_FORM: ModalFormData = {
|
|
name: "",
|
|
weightGrams: "",
|
|
priceDollars: "",
|
|
categoryId: 1,
|
|
notes: "",
|
|
productUrl: "",
|
|
imageFilename: null,
|
|
pros: "",
|
|
cons: "",
|
|
};
|
|
|
|
function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
|
|
const createCandidate = useCreateCandidate(threadId);
|
|
const { currency } = useCurrency();
|
|
const [form, setForm] = useState<ModalFormData>(INITIAL_MODAL_FORM);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
function validate(): boolean {
|
|
const newErrors: Record<string, string> = {};
|
|
if (!form.name.trim()) {
|
|
newErrors.name = "Name is required";
|
|
}
|
|
if (
|
|
form.weightGrams &&
|
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
|
) {
|
|
newErrors.weightGrams = "Must be a positive number";
|
|
}
|
|
if (
|
|
form.priceDollars &&
|
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
|
) {
|
|
newErrors.priceDollars = "Must be a positive number";
|
|
}
|
|
if (
|
|
form.productUrl &&
|
|
form.productUrl.trim() !== "" &&
|
|
!form.productUrl.match(/^https?:\/\//)
|
|
) {
|
|
newErrors.productUrl = "Must be a valid URL (https://...)";
|
|
}
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!validate()) return;
|
|
|
|
createCandidate.mutate(
|
|
{
|
|
name: form.name.trim(),
|
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
|
priceCents: form.priceDollars
|
|
? Math.round(Number(form.priceDollars) * 100)
|
|
: undefined,
|
|
categoryId: form.categoryId,
|
|
notes: form.notes.trim() || undefined,
|
|
productUrl: form.productUrl.trim() || undefined,
|
|
imageFilename: form.imageFilename ?? undefined,
|
|
pros: form.pros.trim() || undefined,
|
|
cons: form.cons.trim() || undefined,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setForm(INITIAL_MODAL_FORM);
|
|
onClose();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
onClick={onClose}
|
|
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
|
>
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
|
|
{/* Modal */}
|
|
<div
|
|
className="relative bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">Add Candidate</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
|
>
|
|
<LucideIcon name="x" size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
|
|
{/* Image */}
|
|
<ImageUpload
|
|
value={form.imageFilename}
|
|
onChange={(filename) =>
|
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
|
}
|
|
/>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-name"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Name *
|
|
</label>
|
|
<input
|
|
id="modal-candidate-name"
|
|
type="text"
|
|
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"
|
|
autoFocus
|
|
/>
|
|
{errors.name && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Weight & Price row */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-weight"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Weight (g)
|
|
</label>
|
|
<input
|
|
id="modal-candidate-weight"
|
|
type="number"
|
|
min="0"
|
|
step="any"
|
|
value={form.weightGrams}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
{errors.weightGrams && (
|
|
<p className="mt-1 text-xs text-red-500">
|
|
{errors.weightGrams}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-price"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{`Price (${currency})`}
|
|
</label>
|
|
<input
|
|
id="modal-candidate-price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={form.priceDollars}
|
|
onChange={(e) =>
|
|
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"
|
|
/>
|
|
{errors.priceDollars && (
|
|
<p className="mt-1 text-xs text-red-500">
|
|
{errors.priceDollars}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Category
|
|
</label>
|
|
<CategoryPicker
|
|
value={form.categoryId}
|
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-notes"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Notes
|
|
</label>
|
|
<textarea
|
|
id="modal-candidate-notes"
|
|
value={form.notes}
|
|
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..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Pros */}
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-pros"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Pros
|
|
</label>
|
|
<textarea
|
|
id="modal-candidate-pros"
|
|
value={form.pros}
|
|
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..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Cons */}
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-cons"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Cons
|
|
</label>
|
|
<textarea
|
|
id="modal-candidate-cons"
|
|
value={form.cons}
|
|
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..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Product Link */}
|
|
<div>
|
|
<label
|
|
htmlFor="modal-candidate-url"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
Product Link
|
|
</label>
|
|
<input
|
|
id="modal-candidate-url"
|
|
type="url"
|
|
value={form.productUrl}
|
|
onChange={(e) =>
|
|
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://..."
|
|
/>
|
|
{errors.productUrl && (
|
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={createCandidate.isPending}
|
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
>
|
|
{createCandidate.isPending ? "Adding..." : "Add Candidate"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="py-2.5 px-4 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|