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}
+
+
+ {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 && (
+
+

+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+ );
+}
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"}
+
+
+ )}
+
+ );
+}