From 0b46eff243c4767671df2bc0b0619e9754ef1281 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 18:04:41 +0200 Subject: [PATCH] feat: add shared setup viewer with token detection and read-only mode Detect ?share=token query param on setup detail page, fetch via /api/shared/:token, and display read-only view with "Shared setup" banner. Hide all owner controls (add items, share, delete, classification) in shared view. Show "Link not available" error for invalid tokens. Plan: 32-04 (Setup Sharing System - Shared Setup Viewer) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/hooks/useSetups.ts | 9 ++++ src/client/routes/setups/$setupId.tsx | 75 +++++++++++++++++++++------ 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index 5ef7b1b..158a17c 100644 --- a/src/client/hooks/useSetups.ts +++ b/src/client/hooks/useSetups.ts @@ -74,6 +74,15 @@ export function usePublicSetup(setupId: number | null) { }); } +export function useSharedSetup(token: string | null) { + return useQuery({ + queryKey: ["shared-setup", token], + queryFn: () => apiGet(`/api/shared/${token}`), + enabled: !!token, + retry: false, + }); +} + export function useCreateSetup() { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index aae970c..8e6abc9 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { z } from "zod"; import { CategoryHeader } from "../../components/CategoryHeader"; import { ItemCard } from "../../components/ItemCard"; import { ItemPicker } from "../../components/ItemPicker"; @@ -12,6 +13,7 @@ import { usePublicSetup, useRemoveSetupItem, useSetup, + useSharedSetup, useUpdateItemClassification, useUpdateSetup, } from "../../hooks/useSetups"; @@ -19,22 +21,35 @@ import { LucideIcon } from "../../lib/iconData"; export const Route = createFileRoute("/setups/$setupId")({ component: SetupDetailPage, + validateSearch: z.object({ + share: z.string().optional(), + }), }); function SetupDetailPage() { const { setupId } = Route.useParams(); + const { share: shareToken } = Route.useSearch(); const { weight, price } = useFormatters(); const navigate = useNavigate(); const numericId = Number(setupId); const { data: auth } = useAuth(); const isAuthenticated = !!auth?.user; + const isSharedView = !!shareToken; - const privateSetup = useSetup(isAuthenticated ? numericId : null); - const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null); - const { data: setup, isLoading } = isAuthenticated - ? privateSetup - : publicSetup; + // Priority: share token > authenticated owner > public viewer + const sharedSetup = useSharedSetup(shareToken ?? null); + const privateSetup = useSetup( + !isSharedView && isAuthenticated ? numericId : null, + ); + const publicSetup = usePublicSetup( + !isSharedView && !isAuthenticated ? numericId : null, + ); + const { + data: setup, + isLoading, + isError: isSharedError, + } = isSharedView ? sharedSetup : isAuthenticated ? privateSetup : publicSetup; const deleteSetup = useDeleteSetup(); const updateSetup = useUpdateSetup(numericId); @@ -60,6 +75,24 @@ function SetupDetailPage() { ); } + if (isSharedView && isSharedError) { + return ( +
+ +

+ Link not available +

+

+ This share link has expired or is no longer valid. +

+
+ ); + } + if (!setup) { return (
@@ -115,8 +148,18 @@ function SetupDetailPage() { }); } + const showOwnerControls = !isSharedView && isAuthenticated; + return (
+ {/* Shared setup banner */} + {isSharedView && setup && ( +
+ + Shared setup +
+ )} + {/* Setup-specific sticky bar */}
@@ -153,8 +196,8 @@ function SetupDetailPage() {
- {/* Actions — only visible to authenticated users */} - {isAuthenticated && ( + {/* Actions — only visible to authenticated owner (hidden in shared view) */} + {showOwnerControls && (
{/* Add Items — desktop */}
)} - {/* Item Picker — only for authenticated users */} - {isAuthenticated && ( + {/* Item Picker — only for authenticated owner */} + {showOwnerControls && ( )} - {/* Share Modal — only for authenticated users */} - {isAuthenticated && ( + {/* Share Modal — only for authenticated owner */} + {showOwnerControls && ( setShareModalOpen(false)} @@ -369,8 +412,8 @@ function SetupDetailPage() { /> )} - {/* Delete Confirmation Dialog — only for authenticated users */} - {isAuthenticated && confirmDelete && ( + {/* Delete Confirmation Dialog — only for authenticated owner */} + {showOwnerControls && confirmDelete && (