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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:04:41 +02:00
parent a531581623
commit 0b46eff243
2 changed files with 68 additions and 16 deletions

View File

@@ -74,6 +74,15 @@ export function usePublicSetup(setupId: number | null) {
}); });
} }
export function useSharedSetup(token: string | null) {
return useQuery({
queryKey: ["shared-setup", token],
queryFn: () => apiGet<SetupWithItems>(`/api/shared/${token}`),
enabled: !!token,
retry: false,
});
}
export function useCreateSetup() { export function useCreateSetup() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
@@ -12,6 +13,7 @@ import {
usePublicSetup, usePublicSetup,
useRemoveSetupItem, useRemoveSetupItem,
useSetup, useSetup,
useSharedSetup,
useUpdateItemClassification, useUpdateItemClassification,
useUpdateSetup, useUpdateSetup,
} from "../../hooks/useSetups"; } from "../../hooks/useSetups";
@@ -19,22 +21,35 @@ import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({ export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPage, component: SetupDetailPage,
validateSearch: z.object({
share: z.string().optional(),
}),
}); });
function SetupDetailPage() { function SetupDetailPage() {
const { setupId } = Route.useParams(); const { setupId } = Route.useParams();
const { share: shareToken } = Route.useSearch();
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
const numericId = Number(setupId); const numericId = Number(setupId);
const { data: auth } = useAuth(); const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user; const isAuthenticated = !!auth?.user;
const isSharedView = !!shareToken;
const privateSetup = useSetup(isAuthenticated ? numericId : null); // Priority: share token > authenticated owner > public viewer
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null); const sharedSetup = useSharedSetup(shareToken ?? null);
const { data: setup, isLoading } = isAuthenticated const privateSetup = useSetup(
? privateSetup !isSharedView && isAuthenticated ? numericId : null,
: publicSetup; );
const publicSetup = usePublicSetup(
!isSharedView && !isAuthenticated ? numericId : null,
);
const {
data: setup,
isLoading,
isError: isSharedError,
} = isSharedView ? sharedSetup : isAuthenticated ? privateSetup : publicSetup;
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId); const updateSetup = useUpdateSetup(numericId);
@@ -60,6 +75,24 @@ function SetupDetailPage() {
); );
} }
if (isSharedView && isSharedError) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<LucideIcon
name="link"
size={48}
className="text-gray-300 mx-auto mb-4"
/>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Link not available
</h2>
<p className="text-sm text-gray-500">
This share link has expired or is no longer valid.
</p>
</div>
);
}
if (!setup) { if (!setup) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
@@ -115,8 +148,18 @@ function SetupDetailPage() {
}); });
} }
const showOwnerControls = !isSharedView && isAuthenticated;
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Shared setup banner */}
{isSharedView && setup && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<LucideIcon name="link" size={16} className="text-blue-500" />
<span className="text-sm text-blue-700">Shared setup</span>
</div>
)}
{/* Setup-specific sticky bar */} {/* 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="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"> <div className="flex items-center justify-between h-12">
@@ -153,8 +196,8 @@ function SetupDetailPage() {
</div> </div>
</div> </div>
{/* Actions — only visible to authenticated users */} {/* Actions — only visible to authenticated owner (hidden in shared view) */}
{isAuthenticated && ( {showOwnerControls && (
<div className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3 py-4">
{/* Add Items — desktop */} {/* Add Items — desktop */}
<button <button
@@ -265,7 +308,7 @@ function SetupDetailPage() {
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Add items from your collection to build this loadout. Add items from your collection to build this loadout.
</p> </p>
{isAuthenticated && ( {showOwnerControls && (
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
@@ -322,13 +365,13 @@ function SetupDetailPage() {
imageUrl={item.imageUrl} imageUrl={item.imageUrl}
productUrl={item.productUrl} productUrl={item.productUrl}
onRemove={ onRemove={
isAuthenticated showOwnerControls
? () => removeItem.mutate(item.id) ? () => removeItem.mutate(item.id)
: undefined : undefined
} }
classification={item.classification} classification={item.classification}
onClassificationCycle={ onClassificationCycle={
isAuthenticated showOwnerControls
? () => ? () =>
updateClassification.mutate({ updateClassification.mutate({
itemId: item.id, itemId: item.id,
@@ -348,8 +391,8 @@ function SetupDetailPage() {
</div> </div>
)} )}
{/* Item Picker — only for authenticated users */} {/* Item Picker — only for authenticated owner */}
{isAuthenticated && ( {showOwnerControls && (
<ItemPicker <ItemPicker
setupId={numericId} setupId={numericId}
currentItemIds={currentItemIds} currentItemIds={currentItemIds}
@@ -358,8 +401,8 @@ function SetupDetailPage() {
/> />
)} )}
{/* Share Modal — only for authenticated users */} {/* Share Modal — only for authenticated owner */}
{isAuthenticated && ( {showOwnerControls && (
<ShareModal <ShareModal
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={() => setShareModalOpen(false)}
@@ -369,8 +412,8 @@ function SetupDetailPage() {
/> />
)} )}
{/* Delete Confirmation Dialog — only for authenticated users */} {/* Delete Confirmation Dialog — only for authenticated owner */}
{isAuthenticated && confirmDelete && ( {showOwnerControls && confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"