diff --git a/src/client/components/ThreadCard.tsx b/src/client/components/ThreadCard.tsx new file mode 100644 index 0000000..c05f6a2 --- /dev/null +++ b/src/client/components/ThreadCard.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from "@tanstack/react-router"; +import { formatPrice } from "../lib/formatters"; + +interface ThreadCardProps { + id: number; + name: string; + candidateCount: number; + minPriceCents: number | null; + maxPriceCents: number | null; + createdAt: string; + status: "active" | "resolved"; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function formatPriceRange( + min: number | null, + max: number | null, +): string | null { + if (min == null && max == null) return null; + if (min === max) return formatPrice(min); + return `${formatPrice(min)} - ${formatPrice(max)}`; +} + +export function ThreadCard({ + id, + name, + candidateCount, + minPriceCents, + maxPriceCents, + createdAt, + status, +}: ThreadCardProps) { + const navigate = useNavigate(); + + const isResolved = status === "resolved"; + const priceRange = formatPriceRange(minPriceCents, maxPriceCents); + + return ( + + ); +} diff --git a/src/client/components/ThreadTabs.tsx b/src/client/components/ThreadTabs.tsx new file mode 100644 index 0000000..a28d5b4 --- /dev/null +++ b/src/client/components/ThreadTabs.tsx @@ -0,0 +1,33 @@ +interface ThreadTabsProps { + active: "gear" | "planning"; + onChange: (tab: "gear" | "planning") => void; +} + +const tabs = [ + { key: "gear" as const, label: "My Gear" }, + { key: "planning" as const, label: "Planning" }, +]; + +export function ThreadTabs({ active, onChange }: ThreadTabsProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} diff --git a/src/client/hooks/useCandidates.ts b/src/client/hooks/useCandidates.ts new file mode 100644 index 0000000..4ddcfe1 --- /dev/null +++ b/src/client/hooks/useCandidates.ts @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiPost, apiPut, apiDelete } from "../lib/api"; +import type { CreateCandidate, UpdateCandidate } from "../../shared/types"; + +interface CandidateResponse { + id: number; + threadId: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryId: number; + notes: string | null; + productUrl: string | null; + imageFilename: string | null; + createdAt: string; + updatedAt: string; +} + +export function useCreateCandidate(threadId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateCandidate & { imageFilename?: string }) => + apiPost(`/api/threads/${threadId}/candidates`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} + +export function useUpdateCandidate(threadId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + candidateId, + ...data + }: UpdateCandidate & { candidateId: number; imageFilename?: string }) => + apiPut( + `/api/threads/${threadId}/candidates/${candidateId}`, + data, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} + +export function useDeleteCandidate(threadId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (candidateId: number) => + apiDelete<{ success: boolean }>( + `/api/threads/${threadId}/candidates/${candidateId}`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts new file mode 100644 index 0000000..b09c7f8 --- /dev/null +++ b/src/client/hooks/useThreads.ts @@ -0,0 +1,113 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; + +interface ThreadListItem { + id: number; + name: string; + status: "active" | "resolved"; + resolvedCandidateId: number | null; + createdAt: string; + updatedAt: string; + candidateCount: number; + minPriceCents: number | null; + maxPriceCents: number | null; +} + +interface CandidateWithCategory { + id: number; + threadId: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryId: number; + notes: string | null; + productUrl: string | null; + imageFilename: string | null; + createdAt: string; + updatedAt: string; + categoryName: string; + categoryEmoji: string; +} + +interface ThreadWithCandidates { + id: number; + name: string; + status: "active" | "resolved"; + resolvedCandidateId: number | null; + createdAt: string; + updatedAt: string; + candidates: CandidateWithCategory[]; +} + +export function useThreads(includeResolved = false) { + return useQuery({ + queryKey: ["threads", { includeResolved }], + queryFn: () => + apiGet( + `/api/threads${includeResolved ? "?includeResolved=true" : ""}`, + ), + }); +} + +export function useThread(threadId: number | null) { + return useQuery({ + queryKey: ["threads", threadId], + queryFn: () => apiGet(`/api/threads/${threadId}`), + enabled: threadId != null, + }); +} + +export function useCreateThread() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string }) => + apiPost("/api/threads", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} + +export function useUpdateThread() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: number; name?: string }) => + apiPut(`/api/threads/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} + +export function useDeleteThread() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/threads/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); +} + +export function useResolveThread() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + threadId, + candidateId, + }: { + threadId: number; + candidateId: number; + }) => + apiPost<{ success: boolean; item: unknown }>( + `/api/threads/${threadId}/resolve`, + { candidateId }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx index 13d7bb2..e1be436 100644 --- a/src/client/routes/index.tsx +++ b/src/client/routes/index.tsx @@ -1,29 +1,55 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; import { useItems } from "../hooks/useItems"; import { useTotals } from "../hooks/useTotals"; +import { useThreads, useCreateThread } from "../hooks/useThreads"; import { CategoryHeader } from "../components/CategoryHeader"; import { ItemCard } from "../components/ItemCard"; +import { ThreadTabs } from "../components/ThreadTabs"; +import { ThreadCard } from "../components/ThreadCard"; import { useUIStore } from "../stores/uiStore"; -export const Route = createFileRoute("/")({ - component: CollectionPage, +const searchSchema = z.object({ + tab: z.enum(["gear", "planning"]).catch("gear"), }); -function CollectionPage() { +export const Route = createFileRoute("/")({ + validateSearch: searchSchema, + component: HomePage, +}); + +function HomePage() { + const { tab } = Route.useSearch(); + const navigate = useNavigate(); + + function handleTabChange(newTab: "gear" | "planning") { + navigate({ to: "/", search: { tab: newTab } }); + } + + return ( +
+ +
+ {tab === "gear" ? : } +
+
+ ); +} + +function CollectionView() { const { data: items, isLoading: itemsLoading } = useItems(); const { data: totals } = useTotals(); const openAddPanel = useUIStore((s) => s.openAddPanel); if (itemsLoading) { return ( -
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))}
); @@ -31,7 +57,7 @@ function CollectionPage() { if (!items || items.length === 0) { return ( -
+
🎒

@@ -101,7 +127,7 @@ function CollectionPage() { } return ( -
+ <> {Array.from(groupedItems.entries()).map( ([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => { const catTotals = categoryTotalsMap.get(categoryId); @@ -133,6 +159,94 @@ function CollectionPage() { ); }, )} + + ); +} + +function PlanningView() { + const [showResolved, setShowResolved] = useState(false); + const [newThreadName, setNewThreadName] = useState(""); + const { data: threads, isLoading } = useThreads(showResolved); + const createThread = useCreateThread(); + + function handleCreateThread(e: React.FormEvent) { + e.preventDefault(); + const name = newThreadName.trim(); + if (!name) return; + createThread.mutate( + { name }, + { onSuccess: () => setNewThreadName("") }, + ); + } + + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ {/* Create thread form */} +
+ setNewThreadName(e.target.value)} + placeholder="New thread 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" + /> + +
+ + {/* Show resolved toggle */} + + + {/* Thread list */} + {!threads || threads.length === 0 ? ( +
+
🔍
+

+ No planning threads yet +

+

+ Start one to research your next purchase. +

+
+ ) : ( +
+ {threads.map((thread) => ( + + ))} +
+ )}
); } diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx new file mode 100644 index 0000000..8f88d39 --- /dev/null +++ b/src/client/routes/threads/$threadId.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/threads/$threadId")({ + component: ThreadDetailPage, +}); + +function ThreadDetailPage() { + const { threadId } = Route.useParams(); + + return ( +
+

Thread {threadId} - detail page placeholder

+
+ ); +} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 1588038..2b82fdd 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; interface UIState { + // Item panel state panelMode: "closed" | "add" | "edit"; editingItemId: number | null; confirmDeleteItemId: number | null; @@ -10,9 +11,28 @@ interface UIState { closePanel: () => void; openConfirmDelete: (itemId: number) => void; closeConfirmDelete: () => void; + + // Candidate panel state + candidatePanelMode: "closed" | "add" | "edit"; + editingCandidateId: number | null; + confirmDeleteCandidateId: number | null; + + openCandidateAddPanel: () => void; + openCandidateEditPanel: (id: number) => void; + closeCandidatePanel: () => void; + openConfirmDeleteCandidate: (id: number) => void; + closeConfirmDeleteCandidate: () => void; + + // Resolution dialog state + resolveThreadId: number | null; + resolveCandidateId: number | null; + + openResolveDialog: (threadId: number, candidateId: number) => void; + closeResolveDialog: () => void; } export const useUIStore = create((set) => ({ + // Item panel panelMode: "closed", editingItemId: null, confirmDeleteItemId: null, @@ -22,4 +42,29 @@ export const useUIStore = create((set) => ({ closePanel: () => set({ panelMode: "closed", editingItemId: null }), openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }), closeConfirmDelete: () => set({ confirmDeleteItemId: null }), + + // Candidate panel + candidatePanelMode: "closed", + editingCandidateId: null, + confirmDeleteCandidateId: null, + + openCandidateAddPanel: () => + set({ candidatePanelMode: "add", editingCandidateId: null }), + openCandidateEditPanel: (id) => + set({ candidatePanelMode: "edit", editingCandidateId: id }), + closeCandidatePanel: () => + set({ candidatePanelMode: "closed", editingCandidateId: null }), + openConfirmDeleteCandidate: (id) => + set({ confirmDeleteCandidateId: id }), + closeConfirmDeleteCandidate: () => + set({ confirmDeleteCandidateId: null }), + + // Resolution dialog + resolveThreadId: null, + resolveCandidateId: null, + + openResolveDialog: (threadId, candidateId) => + set({ resolveThreadId: threadId, resolveCandidateId: candidateId }), + closeResolveDialog: () => + set({ resolveThreadId: null, resolveCandidateId: null }), }));