refactor: replace remaining emojis with Lucide icons

Replace all raw emoji characters in dashboard cards, empty states,
and onboarding wizard with LucideIcon components for visual consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:47:50 +01:00
parent 407fa45280
commit 7c3740fc72
7 changed files with 853 additions and 818 deletions

View File

@@ -1,50 +1,50 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react"; import { LucideIcon } from "../lib/iconData";
interface DashboardCardProps { interface DashboardCardProps {
to: string; to: string;
search?: Record<string, string>; search?: Record<string, string>;
title: string; title: string;
icon: ReactNode; icon: string;
stats: Array<{ label: string; value: string }>; stats: Array<{ label: string; value: string }>;
emptyText?: string; emptyText?: string;
} }
export function DashboardCard({ export function DashboardCard({
to, to,
search, search,
title, title,
icon, icon,
stats, stats,
emptyText, emptyText,
}: DashboardCardProps) { }: DashboardCardProps) {
const allZero = stats.every( const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g", (s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
); );
return ( return (
<Link <Link
to={to} to={to}
search={search} search={search}
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6" className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
> >
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{icon}</span> <LucideIcon name={icon} size={24} className="text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{title}</h2> <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{stats.map((stat) => ( {stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between"> <div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span> <span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{stat.value} {stat.value}
</span> </span>
</div> </div>
))} ))}
</div> </div>
{allZero && emptyText && ( {allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p> <p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
)} )}
</Link> </Link>
); );
} }

View File

@@ -2,315 +2,320 @@ import { useState } from "react";
import { useCreateCategory } from "../hooks/useCategories"; import { useCreateCategory } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems"; import { useCreateItem } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings"; import { useUpdateSetting } from "../hooks/useSettings";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
interface OnboardingWizardProps { interface OnboardingWizardProps {
onComplete: () => void; onComplete: () => void;
} }
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
// Step 2 state // Step 2 state
const [categoryName, setCategoryName] = useState(""); const [categoryName, setCategoryName] = useState("");
const [categoryIcon, setCategoryIcon] = useState(""); const [categoryIcon, setCategoryIcon] = useState("");
const [categoryError, setCategoryError] = useState(""); const [categoryError, setCategoryError] = useState("");
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(null); const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(
null,
);
// Step 3 state // Step 3 state
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [itemWeight, setItemWeight] = useState(""); const [itemWeight, setItemWeight] = useState("");
const [itemPrice, setItemPrice] = useState(""); const [itemPrice, setItemPrice] = useState("");
const [itemError, setItemError] = useState(""); const [itemError, setItemError] = useState("");
const createCategory = useCreateCategory(); const createCategory = useCreateCategory();
const createItem = useCreateItem(); const createItem = useCreateItem();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
function handleSkip() { function handleSkip() {
updateSetting.mutate( updateSetting.mutate(
{ key: "onboardingComplete", value: "true" }, { key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete }, { onSuccess: onComplete },
); );
} }
function handleCreateCategory() { function handleCreateCategory() {
const name = categoryName.trim(); const name = categoryName.trim();
if (!name) { if (!name) {
setCategoryError("Please enter a category name"); setCategoryError("Please enter a category name");
return; return;
} }
setCategoryError(""); setCategoryError("");
createCategory.mutate( createCategory.mutate(
{ name, icon: categoryIcon.trim() || undefined }, { name, icon: categoryIcon.trim() || undefined },
{ {
onSuccess: (created) => { onSuccess: (created) => {
setCreatedCategoryId(created.id); setCreatedCategoryId(created.id);
setStep(3); setStep(3);
}, },
onError: (err) => { onError: (err) => {
setCategoryError(err.message || "Failed to create category"); setCategoryError(err.message || "Failed to create category");
}, },
}, },
); );
} }
function handleCreateItem() { function handleCreateItem() {
const name = itemName.trim(); const name = itemName.trim();
if (!name) { if (!name) {
setItemError("Please enter an item name"); setItemError("Please enter an item name");
return; return;
} }
if (!createdCategoryId) return; if (!createdCategoryId) return;
setItemError(""); setItemError("");
const payload: any = { const payload: any = {
name, name,
categoryId: createdCategoryId, categoryId: createdCategoryId,
}; };
if (itemWeight) payload.weightGrams = Number(itemWeight); if (itemWeight) payload.weightGrams = Number(itemWeight);
if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100); if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100);
createItem.mutate(payload, { createItem.mutate(payload, {
onSuccess: () => setStep(4), onSuccess: () => setStep(4),
onError: (err) => { onError: (err) => {
setItemError(err.message || "Failed to add item"); setItemError(err.message || "Failed to add item");
}, },
}); });
} }
function handleDone() { function handleDone() {
updateSetting.mutate( updateSetting.mutate(
{ key: "onboardingComplete", value: "true" }, { key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete }, { onSuccess: onComplete },
); );
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
{/* Card */} {/* Card */}
<div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8"> <div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
{/* Step indicator */} {/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-6"> <div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => ( {[1, 2, 3].map((s) => (
<div <div
key={s} key={s}
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) s <= Math.min(step, 3) ? "bg-blue-600 w-8" : "bg-gray-200 w-6"
? "bg-blue-600 w-8" }`}
: "bg-gray-200 w-6" />
}`} ))}
/> </div>
))}
</div>
{/* Step 1: Welcome */} {/* Step 1: Welcome */}
{step === 1 && ( {step === 1 && (
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-2"> <h2 className="text-2xl font-semibold text-gray-900 mb-2">
Welcome to GearBox! Welcome to GearBox!
</h2> </h2>
<p className="text-gray-500 mb-8 leading-relaxed"> <p className="text-gray-500 mb-8 leading-relaxed">
Track your gear, compare weights, and plan smarter purchases. Track your gear, compare weights, and plan smarter purchases.
Let&apos;s set up your first category and item. Let&apos;s set up your first category and item.
</p> </p>
<button <button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors" className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
> >
Get Started Get Started
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 2: Create category */} {/* Step 2: Create category */}
{step === 2 && ( {step === 2 && (
<div> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-1"> <h2 className="text-xl font-semibold text-gray-900 mb-1">
Create a category Create a category
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Categories help you organize your gear (e.g. Shelter, Cooking, Categories help you organize your gear (e.g. Shelter, Cooking,
Clothing). Clothing).
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor="onboard-cat-name" htmlFor="onboard-cat-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Category name * Category name *
</label> </label>
<input <input
id="onboard-cat-name" id="onboard-cat-name"
type="text" type="text"
value={categoryName} value={categoryName}
onChange={(e) => setCategoryName(e.target.value)} onChange={(e) => setCategoryName(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-blue-500 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-blue-500 focus:border-transparent"
placeholder="e.g. Shelter" placeholder="e.g. Shelter"
autoFocus autoFocus
/> />
</div> </div>
<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">
Icon (optional) Icon (optional)
</label> </label>
<IconPicker <IconPicker
value={categoryIcon} value={categoryIcon}
onChange={setCategoryIcon} onChange={setCategoryIcon}
size="md" size="md"
/> />
</div> </div>
{categoryError && ( {categoryError && (
<p className="text-xs text-red-500">{categoryError}</p> <p className="text-xs text-red-500">{categoryError}</p>
)} )}
</div> </div>
<button <button
type="button" type="button"
onClick={handleCreateCategory} onClick={handleCreateCategory}
disabled={createCategory.isPending} disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createCategory.isPending ? "Creating..." : "Create Category"} {createCategory.isPending ? "Creating..." : "Create Category"}
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 3: Add item */} {/* Step 3: Add item */}
{step === 3 && ( {step === 3 && (
<div> <div>
<h2 className="text-xl font-semibold text-gray-900 mb-1"> <h2 className="text-xl font-semibold text-gray-900 mb-1">
Add your first item Add your first item
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Add a piece of gear to your collection. Add a piece of gear to your collection.
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor="onboard-item-name" htmlFor="onboard-item-name"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Item name * Item name *
</label> </label>
<input <input
id="onboard-item-name" id="onboard-item-name"
type="text" type="text"
value={itemName} value={itemName}
onChange={(e) => setItemName(e.target.value)} onChange={(e) => setItemName(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-blue-500 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-blue-500 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur" placeholder="e.g. Big Agnes Copper Spur"
autoFocus autoFocus
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label <label
htmlFor="onboard-item-weight" htmlFor="onboard-item-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) Weight (g)
</label> </label>
<input <input
id="onboard-item-weight" id="onboard-item-weight"
type="number" type="number"
min="0" min="0"
step="any" step="any"
value={itemWeight} value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)} onChange={(e) => setItemWeight(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-blue-500 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-blue-500 focus:border-transparent"
placeholder="e.g. 1200" placeholder="e.g. 1200"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="onboard-item-price" htmlFor="onboard-item-price"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Price ($) Price ($)
</label> </label>
<input <input
id="onboard-item-price" id="onboard-item-price"
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
value={itemPrice} value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)} onChange={(e) => setItemPrice(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-blue-500 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-blue-500 focus:border-transparent"
placeholder="e.g. 349.99" placeholder="e.g. 349.99"
/> />
</div> </div>
</div> </div>
{itemError && ( {itemError && <p className="text-xs text-red-500">{itemError}</p>}
<p className="text-xs text-red-500">{itemError}</p> </div>
)}
</div>
<button <button
type="button" type="button"
onClick={handleCreateItem} onClick={handleCreateItem}
disabled={createItem.isPending} disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" className="mt-6 w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
> >
{createItem.isPending ? "Adding..." : "Add Item"} {createItem.isPending ? "Adding..." : "Add Item"}
</button> </button>
<button <button
type="button" type="button"
onClick={handleSkip} onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors" className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
Skip setup Skip setup
</button> </button>
</div> </div>
)} )}
{/* Step 4: Done */} {/* Step 4: Done */}
{step === 4 && ( {step === 4 && (
<div className="text-center"> <div className="text-center">
<div className="text-4xl mb-4">&#127881;</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
You&apos;re all set! name="party-popper"
</h2> size={48}
<p className="text-sm text-gray-500 mb-8"> className="text-gray-400 mx-auto"
Your first item has been added. You can now browse your collection, />
add more gear, and track your setup. </div>
</p> <h2 className="text-xl font-semibold text-gray-900 mb-2">
<button You&apos;re all set!
type="button" </h2>
onClick={handleDone} <p className="text-sm text-gray-500 mb-8">
disabled={updateSetting.isPending} Your first item has been added. You can now browse your
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" collection, add more gear, and track your setup.
> </p>
{updateSetting.isPending ? "Finishing..." : "Done"} <button
</button> type="button"
</div> onClick={handleDone}
)} disabled={updateSetting.isPending}
</div> className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
</div> >
); {updateSetting.isPending ? "Finishing..." : "Done"}
</button>
</div>
)}
</div>
</div>
);
} }

View File

@@ -10,6 +10,7 @@ import { useCategories } from "../../hooks/useCategories";
import { useItems } from "../../hooks/useItems"; import { useItems } from "../../hooks/useItems";
import { useThreads } from "../../hooks/useThreads"; import { useThreads } from "../../hooks/useThreads";
import { useTotals } from "../../hooks/useTotals"; import { useTotals } from "../../hooks/useTotals";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({ const searchSchema = z.object({
@@ -61,7 +62,13 @@ function CollectionView() {
return ( return (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div> <div className="mb-4">
<LucideIcon
name="backpack"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty Your collection is empty
</h2> </h2>
@@ -158,6 +165,7 @@ function CollectionView() {
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
productUrl={item.productUrl}
/> />
))} ))}
</div> </div>

View File

@@ -1,55 +1,56 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
import { useThreads } from "../hooks/useThreads";
import { useSetups } from "../hooks/useSetups";
import { DashboardCard } from "../components/DashboardCard"; import { DashboardCard } from "../components/DashboardCard";
import { formatWeight, formatPrice } from "../lib/formatters"; import { useSetups } from "../hooks/useSetups";
import { useThreads } from "../hooks/useThreads";
import { useTotals } from "../hooks/useTotals";
import { formatPrice, formatWeight } from "../lib/formatters";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: DashboardPage, component: DashboardPage,
}); });
function DashboardPage() { function DashboardPage() {
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const { data: threads } = useThreads(false); const { data: threads } = useThreads(false);
const { data: setups } = useSetups(); const { data: setups } = useSetups();
const global = totals?.global; const global = totals?.global;
const activeThreadCount = threads?.length ?? 0; const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0; const setupCount = setups?.length ?? 0;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DashboardCard <DashboardCard
to="/collection" to="/collection"
title="Collection" title="Collection"
icon="🎒" icon="backpack"
stats={[ stats={[
{ label: "Items", value: String(global?.itemCount ?? 0) }, { label: "Items", value: String(global?.itemCount ?? 0) },
{ label: "Weight", value: formatWeight(global?.totalWeight ?? null) }, {
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) }, label: "Weight",
]} value: formatWeight(global?.totalWeight ?? null),
emptyText="Get started" },
/> { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
<DashboardCard ]}
to="/collection" emptyText="Get started"
search={{ tab: "planning" }} />
title="Planning" <DashboardCard
icon="🔍" to="/collection"
stats={[ search={{ tab: "planning" }}
{ label: "Active threads", value: String(activeThreadCount) }, title="Planning"
]} icon="search"
/> stats={[
<DashboardCard { label: "Active threads", value: String(activeThreadCount) },
to="/setups" ]}
title="Setups" />
icon="🏕️" <DashboardCard
stats={[ to="/setups"
{ label: "Setups", value: String(setupCount) }, title="Setups"
]} icon="tent"
/> stats={[{ label: "Setups", value: String(setupCount) }]}
</div> />
</div> </div>
); </div>
);
} }

View File

@@ -1,268 +1,276 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { import { useState } from "react";
useSetup,
useDeleteSetup,
useRemoveSetupItem,
} from "../../hooks/useSetups";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
import { formatWeight, formatPrice } from "../../lib/formatters"; import {
useDeleteSetup,
useRemoveSetupItem,
useSetup,
} from "../../hooks/useSetups";
import { formatPrice, formatWeight } from "../../lib/formatters";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({ export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPage, component: SetupDetailPage,
}); });
function SetupDetailPage() { function SetupDetailPage() {
const { setupId } = Route.useParams(); const { setupId } = Route.useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const numericId = Number(setupId); const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId); const { data: setup, isLoading } = useSetup(numericId);
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const removeItem = useRemoveSetupItem(numericId); const removeItem = useRemoveSetupItem(numericId);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-8 bg-gray-200 rounded w-48" /> <div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (!setup) { if (!setup) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">Setup not found.</p> <p className="text-gray-500">Setup not found.</p>
</div> </div>
); );
} }
// Compute totals from items // Compute totals from items
const totalWeight = setup.items.reduce( const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const totalCost = setup.items.reduce( const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
const itemCount = setup.items.length; const itemCount = setup.items.length;
const currentItemIds = setup.items.map((item) => item.id); const currentItemIds = setup.items.map((item) => item.id);
// Group items by category // Group items by category
const groupedItems = new Map< const groupedItems = new Map<
number, number,
{ {
items: typeof setup.items; items: typeof setup.items;
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
} }
>(); >();
for (const item of setup.items) { for (const item of setup.items) {
const group = groupedItems.get(item.categoryId); const group = groupedItems.get(item.categoryId);
if (group) { if (group) {
group.items.push(item); group.items.push(item);
} else { } else {
groupedItems.set(item.categoryId, { groupedItems.set(item.categoryId, {
items: [item], items: [item],
categoryName: item.categoryName, categoryName: item.categoryName,
categoryIcon: item.categoryIcon, categoryIcon: item.categoryIcon,
}); });
} }
} }
function handleDelete() { function handleDelete() {
deleteSetup.mutate(numericId, { deleteSetup.mutate(numericId, {
onSuccess: () => navigate({ to: "/setups" }), onSuccess: () => navigate({ to: "/setups" }),
}); });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Setup-specific sticky bar */} {/* Setup-specific sticky bar */}
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8"> <div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-12"> <div className="flex items-center justify-between h-12">
<h2 className="text-base font-semibold text-gray-900 truncate"> <h2 className="text-base font-semibold text-gray-900 truncate">
{setup.name} {setup.name}
</h2> </h2>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-4 text-sm text-gray-500">
<span> <span>
<span className="font-medium text-gray-700">{itemCount}</span>{" "} <span className="font-medium text-gray-700">{itemCount}</span>{" "}
{itemCount === 1 ? "item" : "items"} {itemCount === 1 ? "item" : "items"}
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatWeight(totalWeight)} {formatWeight(totalWeight)}
</span>{" "} </span>{" "}
total total
</span> </span>
<span> <span>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{formatPrice(totalCost)} {formatPrice(totalCost)}
</span>{" "} </span>{" "}
cost cost
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3 py-4">
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Items Add Items
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
> >
Delete Setup Delete Setup
</button> </button>
</div> </div>
{/* Empty state */} {/* Empty state */}
{itemCount === 0 && ( {itemCount === 0 && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">📦</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
No items in this setup name="package"
</h2> size={48}
<p className="text-sm text-gray-500 mb-6"> className="text-gray-400 mx-auto"
Add items from your collection to build this loadout. />
</p> </div>
<button <h2 className="text-xl font-semibold text-gray-900 mb-2">
type="button" No items in this setup
onClick={() => setPickerOpen(true)} </h2>
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" <p className="text-sm text-gray-500 mb-6">
> Add items from your collection to build this loadout.
Add Items </p>
</button> <button
</div> type="button"
</div> onClick={() => setPickerOpen(true)}
)} className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Add Items
</button>
</div>
</div>
)}
{/* Items grouped by category */} {/* Items grouped by category */}
{itemCount > 0 && ( {itemCount > 0 && (
<div className="pb-6"> <div className="pb-6">
{Array.from(groupedItems.entries()).map( {Array.from(groupedItems.entries()).map(
([ ([
categoryId, categoryId,
{ items: categoryItems, categoryName, categoryIcon }, { items: categoryItems, categoryName, categoryIcon },
]) => { ]) => {
const catWeight = categoryItems.reduce( const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0), (sum, item) => sum + (item.weightGrams ?? 0),
0, 0,
); );
const catCost = categoryItems.reduce( const catCost = categoryItems.reduce(
(sum, item) => sum + (item.priceCents ?? 0), (sum, item) => sum + (item.priceCents ?? 0),
0, 0,
); );
return ( return (
<div key={categoryId} className="mb-8"> <div key={categoryId} className="mb-8">
<CategoryHeader <CategoryHeader
categoryId={categoryId} categoryId={categoryId}
name={categoryName} name={categoryName}
icon={categoryIcon} icon={categoryIcon}
totalWeight={catWeight} totalWeight={catWeight}
totalCost={catCost} totalCost={catCost}
itemCount={categoryItems.length} itemCount={categoryItems.length}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => ( {categoryItems.map((item) => (
<ItemCard <ItemCard
key={item.id} key={item.id}
id={item.id} id={item.id}
name={item.name} name={item.name}
weightGrams={item.weightGrams} weightGrams={item.weightGrams}
priceCents={item.priceCents} priceCents={item.priceCents}
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
onRemove={() => removeItem.mutate(item.id)} productUrl={item.productUrl}
/> onRemove={() => removeItem.mutate(item.id)}
))} />
</div> ))}
</div> </div>
); </div>
}, );
)} },
</div> )}
)} </div>
)}
{/* Item Picker */} {/* Item Picker */}
<ItemPicker <ItemPicker
setupId={numericId} setupId={numericId}
currentItemIds={currentItemIds} currentItemIds={currentItemIds}
isOpen={pickerOpen} isOpen={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
{confirmDelete && ( {confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
/> />
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full"> <div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Setup Delete Setup
</h3> </h3>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-medium">{setup.name}</span>? This will not <span className="font-medium">{setup.name}</span>? This will not
remove items from your collection. remove items from your collection.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
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 Cancel
</button> </button>
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteSetup.isPending} disabled={deleteSetup.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
> >
{deleteSetup.isPending ? "Deleting..." : "Delete"} {deleteSetup.isPending ? "Deleting..." : "Delete"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,86 +1,93 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useSetups, useCreateSetup } from "../../hooks/useSetups"; import { useState } from "react";
import { SetupCard } from "../../components/SetupCard"; import { SetupCard } from "../../components/SetupCard";
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/")({ export const Route = createFileRoute("/setups/")({
component: SetupsListPage, component: SetupsListPage,
}); });
function SetupsListPage() { function SetupsListPage() {
const [newSetupName, setNewSetupName] = useState(""); const [newSetupName, setNewSetupName] = useState("");
const { data: setups, isLoading } = useSetups(); const { data: setups, isLoading } = useSetups();
const createSetup = useCreateSetup(); const createSetup = useCreateSetup();
function handleCreateSetup(e: React.FormEvent) { function handleCreateSetup(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const name = newSetupName.trim(); const name = newSetupName.trim();
if (!name) return; if (!name) return;
createSetup.mutate( createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
{ name }, }
{ onSuccess: () => setNewSetupName("") },
);
}
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Create setup form */} {/* Create setup form */}
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6"> <form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
<input <input
type="text" type="text"
value={newSetupName} value={newSetupName}
onChange={(e) => setNewSetupName(e.target.value)} onChange={(e) => setNewSetupName(e.target.value)}
placeholder="New setup name..." placeholder="New setup name..."
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button <button
type="submit" type="submit"
disabled={!newSetupName.trim() || createSetup.isPending} disabled={!newSetupName.trim() || createSetup.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
> >
{createSetup.isPending ? "Creating..." : "Create"} {createSetup.isPending ? "Creating..." : "Create"}
</button> </button>
</form> </form>
{/* Loading skeleton */} {/* Loading skeleton */}
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl animate-pulse" /> <div
))} key={i}
</div> className="h-24 bg-gray-200 rounded-xl animate-pulse"
)} />
))}
</div>
)}
{/* Empty state */} {/* Empty state */}
{!isLoading && (!setups || setups.length === 0) && ( {!isLoading && (!setups || setups.length === 0) && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🏕</div> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <LucideIcon
No setups yet name="tent"
</h2> size={48}
<p className="text-sm text-gray-500"> className="text-gray-400 mx-auto"
Create one to plan your loadout. />
</p> </div>
</div> <h2 className="text-xl font-semibold text-gray-900 mb-2">
</div> No setups yet
)} </h2>
<p className="text-sm text-gray-500">
Create one to plan your loadout.
</p>
</div>
</div>
)}
{/* Setup grid */} {/* Setup grid */}
{!isLoading && setups && setups.length > 0 && ( {!isLoading && setups && setups.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{setups.map((setup) => ( {setups.map((setup) => (
<SetupCard <SetupCard
key={setup.id} key={setup.id}
id={setup.id} id={setup.id}
name={setup.name} name={setup.name}
itemCount={setup.itemCount} itemCount={setup.itemCount}
totalWeight={setup.totalWeight} totalWeight={setup.totalWeight}
totalCost={setup.totalCost} totalCost={setup.totalCost}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,147 +1,153 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useThread } from "../../hooks/useThreads";
import { CandidateCard } from "../../components/CandidateCard"; import { CandidateCard } from "../../components/CandidateCard";
import { useThread } from "../../hooks/useThreads";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
export const Route = createFileRoute("/threads/$threadId")({ export const Route = createFileRoute("/threads/$threadId")({
component: ThreadDetailPage, component: ThreadDetailPage,
}); });
function ThreadDetailPage() { function ThreadDetailPage() {
const { threadId: threadIdParam } = Route.useParams(); const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam); const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId); const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel); const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <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="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" /> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (isError || !thread) { if (isError || !thread) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <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"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Thread not found Thread not found
</h2> </h2>
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-blue-600 hover:text-blue-700" className="text-sm text-blue-600 hover:text-blue-700"
> >
Back to planning Back to planning
</Link> </Link>
</div> </div>
); );
} }
const isActive = thread.status === "active"; const isActive = thread.status === "active";
const winningCandidate = thread.resolvedCandidateId const winningCandidate = thread.resolvedCandidateId
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId) ? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
: null; : null;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<Link <Link
to="/" to="/"
search={{ tab: "planning" }} search={{ tab: "planning" }}
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
&larr; Back to planning &larr; Back to planning
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-900"> <h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
{thread.name} <span
</h1> className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
<span isActive
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ ? "bg-blue-50 text-blue-700"
isActive : "bg-gray-100 text-gray-500"
? "bg-blue-50 text-blue-700" }`}
: "bg-gray-100 text-gray-500" >
}`} {isActive ? "Active" : "Resolved"}
> </span>
{isActive ? "Active" : "Resolved"} </div>
</span> </div>
</div>
</div>
{/* Resolution banner */} {/* Resolution banner */}
{!isActive && winningCandidate && ( {!isActive && winningCandidate && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl"> <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<span className="font-medium">{winningCandidate.name}</span> was <span className="font-medium">{winningCandidate.name}</span> was
picked as the winner and added to your collection. picked as the winner and added to your collection.
</p> </p>
</div> </div>
)} )}
{/* Add candidate button */} {/* Add candidate button */}
{isActive && ( {isActive && (
<div className="mb-6"> <div className="mb-6">
<button <button
type="button" type="button"
onClick={openCandidateAddPanel} onClick={openCandidateAddPanel}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Candidate Add Candidate
</button> </button>
</div> </div>
)} )}
{/* Candidate grid */} {/* Candidate grid */}
{thread.candidates.length === 0 ? ( {thread.candidates.length === 0 ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="text-4xl mb-3">🏷</div> <div className="mb-3">
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <LucideIcon
No candidates yet name="tag"
</h3> size={48}
<p className="text-sm text-gray-500"> className="text-gray-400 mx-auto"
Add your first candidate to start comparing. />
</p> </div>
</div> <h3 className="text-lg font-semibold text-gray-900 mb-1">
) : ( No candidates yet
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> </h3>
{thread.candidates.map((candidate) => ( <p className="text-sm text-gray-500">
<CandidateCard Add your first candidate to start comparing.
key={candidate.id} </p>
id={candidate.id} </div>
name={candidate.name} ) : (
weightGrams={candidate.weightGrams} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
priceCents={candidate.priceCents} {thread.candidates.map((candidate) => (
categoryName={candidate.categoryName} <CandidateCard
categoryIcon={candidate.categoryIcon} key={candidate.id}
imageFilename={candidate.imageFilename} id={candidate.id}
threadId={threadId} name={candidate.name}
isActive={isActive} weightGrams={candidate.weightGrams}
/> priceCents={candidate.priceCents}
))} categoryName={candidate.categoryName}
</div> categoryIcon={candidate.categoryIcon}
)} imageFilename={candidate.imageFilename}
</div> productUrl={candidate.productUrl}
); threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
} }