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 type { ReactNode } from "react";
import { LucideIcon } from "../lib/iconData";
interface DashboardCardProps {
to: string;
search?: Record<string, string>;
title: string;
icon: ReactNode;
stats: Array<{ label: string; value: string }>;
emptyText?: string;
to: string;
search?: Record<string, string>;
title: string;
icon: string;
stats: Array<{ label: string; value: string }>;
emptyText?: string;
}
export function DashboardCard({
to,
search,
title,
icon,
stats,
emptyText,
to,
search,
title,
icon,
stats,
emptyText,
}: DashboardCardProps) {
const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
);
const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
);
return (
<Link
to={to}
search={search}
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">
<span className="text-2xl">{icon}</span>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
<div className="space-y-1.5">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700">
{stat.value}
</span>
</div>
))}
</div>
{allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
)}
</Link>
);
return (
<Link
to={to}
search={search}
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">
<LucideIcon name={icon} size={24} className="text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
<div className="space-y-1.5">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700">
{stat.value}
</span>
</div>
))}
</div>
{allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
)}
</Link>
);
}

View File

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

View File

@@ -1,55 +1,56 @@
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 { 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("/")({
component: DashboardPage,
component: DashboardPage,
});
function DashboardPage() {
const { data: totals } = useTotals();
const { data: threads } = useThreads(false);
const { data: setups } = useSetups();
const { data: totals } = useTotals();
const { data: threads } = useThreads(false);
const { data: setups } = useSetups();
const global = totals?.global;
const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0;
const global = totals?.global;
const activeThreadCount = threads?.length ?? 0;
const setupCount = setups?.length ?? 0;
return (
<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">
<DashboardCard
to="/collection"
title="Collection"
icon="🎒"
stats={[
{ label: "Items", value: String(global?.itemCount ?? 0) },
{ label: "Weight", value: formatWeight(global?.totalWeight ?? null) },
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) },
]}
emptyText="Get started"
/>
<DashboardCard
to="/collection"
search={{ tab: "planning" }}
title="Planning"
icon="🔍"
stats={[
{ label: "Active threads", value: String(activeThreadCount) },
]}
/>
<DashboardCard
to="/setups"
title="Setups"
icon="🏕️"
stats={[
{ label: "Setups", value: String(setupCount) },
]}
/>
</div>
</div>
);
return (
<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">
<DashboardCard
to="/collection"
title="Collection"
icon="backpack"
stats={[
{ label: "Items", value: String(global?.itemCount ?? 0) },
{
label: "Weight",
value: formatWeight(global?.totalWeight ?? null),
},
{ label: "Cost", value: formatPrice(global?.totalCost ?? null) },
]}
emptyText="Get started"
/>
<DashboardCard
to="/collection"
search={{ tab: "planning" }}
title="Planning"
icon="search"
stats={[
{ label: "Active threads", value: String(activeThreadCount) },
]}
/>
<DashboardCard
to="/setups"
title="Setups"
icon="tent"
stats={[{ label: "Setups", value: String(setupCount) }]}
/>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,147 +1,153 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useThread } from "../../hooks/useThreads";
import { CandidateCard } from "../../components/CandidateCard";
import { useThread } from "../../hooks/useThreads";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
export const Route = createFileRoute("/threads/$threadId")({
component: ThreadDetailPage,
component: ThreadDetailPage,
});
function ThreadDetailPage() {
const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
const { threadId: threadIdParam } = Route.useParams();
const threadId = Number(threadIdParam);
const { data: thread, isLoading, isError } = useThread(threadId);
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
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 (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-blue-600 hover:text-blue-700"
>
Back to planning
</Link>
</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-blue-600 hover:text-blue-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 isActive = thread.status === "active";
const winningCandidate = thread.resolvedCandidateId
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
: null;
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"
>
&larr; 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-blue-50 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{isActive ? "Active" : "Resolved"}
</span>
</div>
</div>
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"
>
&larr; 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-blue-50 text-blue-700"
: "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>
)}
{/* 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>
)}
{/* Add candidate button */}
{isActive && (
<div className="mb-6">
<button
type="button"
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"
>
<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>
</div>
)}
{/* Add candidate button */}
{isActive && (
<div className="mb-6">
<button
type="button"
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"
>
<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>
</div>
)}
{/* Candidate grid */}
{thread.candidates.length === 0 ? (
<div className="py-12 text-center">
<div className="text-4xl mb-3">🏷</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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{thread.candidates.map((candidate) => (
<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}
threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
{/* Candidate grid */}
{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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{thread.candidates.map((candidate) => (
<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}
productUrl={candidate.productUrl}
threadId={threadId}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
}