From f53f66d32157645def6ce19cb52924c8891a4b9b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:17:39 +0200 Subject: [PATCH] feat(18-04): add global item hooks, catalog browse page, and detail page - useGlobalItems/useGlobalItem/useLinkItem/useUnlinkItem hooks - Global catalog browse page with search, debounce, and skeleton loading - Global item detail page with owner count badge - GlobalItemCard component with brand, model, specs badges --- src/client/components/GlobalItemCard.tsx | 83 ++++++++++ src/client/hooks/useGlobalItems.ts | 74 +++++++++ .../routes/global-items/$globalItemId.tsx | 125 +++++++++++++++ src/client/routes/global-items/index.tsx | 142 ++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 src/client/components/GlobalItemCard.tsx create mode 100644 src/client/hooks/useGlobalItems.ts create mode 100644 src/client/routes/global-items/$globalItemId.tsx create mode 100644 src/client/routes/global-items/index.tsx diff --git a/src/client/components/GlobalItemCard.tsx b/src/client/components/GlobalItemCard.tsx new file mode 100644 index 0000000..7aa3944 --- /dev/null +++ b/src/client/components/GlobalItemCard.tsx @@ -0,0 +1,83 @@ +import { Link } from "@tanstack/react-router"; +import { useFormatters } from "../hooks/useFormatters"; + +interface GlobalItemCardProps { + id: number; + brand: string; + model: string; + category: string | null; + weightGrams: number | null; + priceCents: number | null; + imageUrl: string | null; +} + +export function GlobalItemCard({ + id, + brand, + model, + category, + weightGrams, + priceCents, + imageUrl, +}: GlobalItemCardProps) { + const { weight, price } = useFormatters(); + + return ( + +
+ {imageUrl ? ( + {`${brand} + ) : ( +
+ + + +
+ )} +
+
+

+ {brand} +

+

+ {model} +

+
+ {weightGrams != null && ( + + {weight(weightGrams)} + + )} + {priceCents != null && ( + + {price(priceCents)} + + )} + {category && ( + + {category} + + )} +
+
+ + ); +} diff --git a/src/client/hooks/useGlobalItems.ts b/src/client/hooks/useGlobalItems.ts new file mode 100644 index 0000000..f2cb9da --- /dev/null +++ b/src/client/hooks/useGlobalItems.ts @@ -0,0 +1,74 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ApiError, apiDelete, apiGet, apiPost } from "../lib/api"; + +interface GlobalItem { + id: number; + brand: string; + model: string; + category: string | null; + weightGrams: number | null; + priceCents: number | null; + imageUrl: string | null; + description: string | null; + createdAt: string; +} + +interface GlobalItemWithOwnerCount extends GlobalItem { + ownerCount: number; +} + +interface ItemGlobalLink { + id: number; + itemId: number; + globalItemId: number; +} + +export function useGlobalItems(query?: string) { + return useQuery({ + queryKey: ["global-items", query ?? ""], + queryFn: () => + apiGet( + `/api/global-items${query ? `?q=${encodeURIComponent(query)}` : ""}`, + ), + }); +} + +export function useGlobalItem(id: number | null) { + return useQuery({ + queryKey: ["global-items", id], + queryFn: () => apiGet(`/api/global-items/${id}`), + enabled: id != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, + }); +} + +export function useLinkItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + itemId, + globalItemId, + }: { + itemId: number; + globalItemId: number; + }) => + apiPost(`/api/items/${itemId}/link`, { globalItemId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["global-items"] }); + }, + }); +} + +export function useUnlinkItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemId: number) => + apiDelete<{ success: boolean }>(`/api/items/${itemId}/link`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["global-items"] }); + }, + }); +} diff --git a/src/client/routes/global-items/$globalItemId.tsx b/src/client/routes/global-items/$globalItemId.tsx new file mode 100644 index 0000000..a591e8b --- /dev/null +++ b/src/client/routes/global-items/$globalItemId.tsx @@ -0,0 +1,125 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useFormatters } from "../../hooks/useFormatters"; +import { useGlobalItem } from "../../hooks/useGlobalItems"; + +export const Route = createFileRoute("/global-items/$globalItemId")({ + component: GlobalItemDetail, +}); + +function GlobalItemDetail() { + const { globalItemId } = Route.useParams(); + const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId)); + const { weight, price } = useFormatters(); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !item) { + return ( +
+ + ← Back to catalog + +
+

Global item not found

+
+
+ ); + } + + return ( +
+
+ + ← Back to catalog + +
+ + {/* Image */} + {item.imageUrl && ( +
+ {`${item.brand} +
+ )} + + {/* Header */} +
+

+ {item.brand} +

+

{item.model}

+ + {/* Owner count badge */} +
+ + + + {item.ownerCount === 0 + ? "Be the first to add this" + : item.ownerCount === 1 + ? "1 user owns this" + : `${item.ownerCount} users own this`} +
+
+ + {/* Specs */} +
+ {item.weightGrams != null && ( + + {weight(item.weightGrams)} + + )} + {item.priceCents != null && ( + + {price(item.priceCents)} + + )} + {item.category && ( + + {item.category} + + )} +
+ + {/* Description */} + {item.description && ( +
+

{item.description}

+
+ )} +
+ ); +} diff --git a/src/client/routes/global-items/index.tsx b/src/client/routes/global-items/index.tsx new file mode 100644 index 0000000..eeb3c59 --- /dev/null +++ b/src/client/routes/global-items/index.tsx @@ -0,0 +1,142 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { GlobalItemCard } from "../../components/GlobalItemCard"; +import { useGlobalItems } from "../../hooks/useGlobalItems"; + +export const Route = createFileRoute("/global-items/")({ + component: GlobalItemsCatalog, +}); + +function GlobalItemsCatalog() { + const [searchInput, setSearchInput] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchInput); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + const { data: items, isLoading } = useGlobalItems( + debouncedQuery || undefined, + ); + + return ( +
+
+ + ← Dashboard + +
+ +
+

+ Global Gear Catalog +

+

+ Browse and discover gear from the shared catalog +

+
+ + {/* Search */} +
+
+ + + + setSearchInput(e.target.value)} + placeholder="Search gear by brand or model..." + className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300 transition-colors" + /> + {searchInput && ( + + )} +
+
+ + {/* Results */} + {isLoading ? ( +
+ {["a", "b", "c", "d", "e", "f"].map((id) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : items && items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : ( +
+ + + +

+ {debouncedQuery + ? "No items found matching your search" + : "No items in the global catalog yet"} +

+
+ )} +
+ ); +}