---
phase: 24-public-access-infrastructure
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/client/routes/__root.tsx
  - src/client/stores/uiStore.ts
  - src/client/components/AuthPromptModal.tsx
  - src/client/hooks/useSetups.ts
  - src/client/hooks/useSettings.ts
  - src/client/routes/global-items/$globalItemId.tsx
  - src/client/routes/setups/$setupId.tsx
autonomous: false
requirements: [PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05]

must_haves:
  truths:
    - "Anonymous visitor sees app content immediately on any public route — no spinner, no redirect"
    - "Anonymous visitor can browse the global item catalog and open catalog detail pages"
    - "Anonymous visitor can view a public setup with its items and totals"
    - "Anonymous visitor can view a user profile page"
    - "Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt instead of the action"
    - "Authenticated user experience is unchanged — all write actions work as before"
  artifacts:
    - path: "src/client/routes/__root.tsx"
      provides: "Render-first root layout with expanded isPublicRoute"
      contains: "pathname.startsWith(\"/global-items\")"
    - path: "src/client/stores/uiStore.ts"
      provides: "showAuthPrompt state for auth modal"
      contains: "showAuthPrompt"
    - path: "src/client/components/AuthPromptModal.tsx"
      provides: "Modal prompting anonymous users to sign in or sign up"
      contains: "sign in or sign up"
    - path: "src/client/hooks/useSetups.ts"
      provides: "usePublicSetup hook for anonymous setup viewing"
      exports: ["usePublicSetup"]
    - path: "src/client/routes/global-items/$globalItemId.tsx"
      provides: "Auth-guarded write action buttons on catalog detail"
      contains: "openAuthPrompt"
    - path: "src/client/routes/setups/$setupId.tsx"
      provides: "Conditional public vs private setup rendering"
      contains: "usePublicSetup"
  key_links:
    - from: "src/client/routes/__root.tsx"
      to: "src/client/components/AuthPromptModal.tsx"
      via: "rendered in root layout"
      pattern: "<AuthPromptModal"
    - from: "src/client/routes/global-items/$globalItemId.tsx"
      to: "src/client/stores/uiStore.ts"
      via: "openAuthPrompt action"
      pattern: "openAuthPrompt"
    - from: "src/client/routes/setups/$setupId.tsx"
      to: "src/client/hooks/useSetups.ts"
      via: "usePublicSetup hook"
      pattern: "usePublicSetup"
---

<objective>
Make the app render immediately for anonymous visitors, expand public route access to catalog and setups, and intercept write actions with a friendly auth prompt.

Purpose: Transform GearBox from a login-first tool into a public-first browsing experience (PUBL-01 through PUBL-05). Anonymous visitors see content instantly; write actions prompt sign-in/sign-up instead of hard-redirecting.
Output: Reworked root layout, auth prompt modal, public setup hook, guarded write actions.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/24-public-access-infrastructure/24-CONTEXT.md
@.planning/phases/24-public-access-infrastructure/24-RESEARCH.md

@src/client/routes/__root.tsx
@src/client/stores/uiStore.ts
@src/client/hooks/useSetups.ts
@src/client/hooks/useAuth.ts
@src/client/hooks/useSettings.ts
@src/client/routes/global-items/$globalItemId.tsx
@src/client/routes/setups/$setupId.tsx
@src/client/components/TotalsBar.tsx

<interfaces>
<!-- Key types and contracts the executor needs. -->

From src/client/hooks/useAuth.ts:
```typescript
interface AuthState {
  user: { id: string; email?: string } | null;
  authenticated: boolean;
}
export function useAuth(): UseQueryResult<AuthState>;
```

From src/client/stores/uiStore.ts:
```typescript
// Existing pattern — all boolean state with open/close actions
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
```

From src/client/hooks/useSetups.ts:
```typescript
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
// New hook to add:
export function usePublicSetup(id: number): UseQueryResult<PublicSetupData>;
```

From src/client/components/TotalsBar.tsx:
```typescript
// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add auth prompt state to uiStore, create AuthPromptModal, add usePublicSetup hook</name>
  <files>src/client/stores/uiStore.ts, src/client/components/AuthPromptModal.tsx, src/client/hooks/useSetups.ts, src/client/hooks/useSettings.ts</files>
  <read_first>
    - src/client/stores/uiStore.ts (current state shape and patterns)
    - src/client/hooks/useSetups.ts (existing hooks, types)
    - src/client/hooks/useSettings.ts (useOnboardingComplete — needs `enabled` guard)
    - src/client/routes/__root.tsx (CandidateDeleteDialog pattern for modal structure)
    - src/client/components/TotalsBar.tsx (confirm D-10 already handled)
  </read_first>
  <action>
    **1. Extend uiStore.ts** — Add auth prompt state following the existing pattern (e.g., `externalLinkUrl`):

    Add to the `UIState` interface:
    ```typescript
    // Auth prompt modal
    showAuthPrompt: boolean;
    openAuthPrompt: () => void;
    closeAuthPrompt: () => void;
    ```

    Add to the `create` implementation:
    ```typescript
    // Auth prompt modal
    showAuthPrompt: false,
    openAuthPrompt: () => set({ showAuthPrompt: true }),
    closeAuthPrompt: () => set({ showAuthPrompt: false }),
    ```

    **2. Create AuthPromptModal.tsx** per D-06 — inline popup/modal with "sign in or sign up" language. Follow the exact pattern of CandidateDeleteDialog in `__root.tsx` (fixed overlay, centered card, bg-black/30 backdrop):

    ```typescript
    import { Link } from "@tanstack/react-router";
    import { useUIStore } from "../stores/uiStore";

    export function AuthPromptModal() {
      const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
      const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);

      if (!showAuthPrompt) return null;

      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div
            className="absolute inset-0 bg-black/30"
            onClick={closeAuthPrompt}
            onKeyDown={(e) => {
              if (e.key === "Escape") closeAuthPrompt();
            }}
          />
          <div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
            <h3 className="text-lg font-semibold text-gray-900 mb-2">
              Join GearBox
            </h3>
            <p className="text-sm text-gray-600 mb-6">
              To manage your own collection, sign in or sign up.
            </p>
            <div className="flex flex-col gap-3">
              <Link
                to="/login"
                className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
                onClick={closeAuthPrompt}
              >
                Sign in
              </Link>
              <Link
                to="/login"
                className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
                onClick={closeAuthPrompt}
              >
                Create account
              </Link>
            </div>
          </div>
        </div>
      );
    }
    ```

    Both links go to `/login` because Logto handles both sign-in and sign-up at the same OIDC redirect. The UX distinction is in the button labels per the user's emphasis on welcoming new users (from specifics in CONTEXT.md).

    **3. Add usePublicSetup hook** in `src/client/hooks/useSetups.ts`:

    Add after the existing `useSetup` function:
    ```typescript
    export function usePublicSetup(setupId: number | null) {
      return useQuery({
        queryKey: ["setups", setupId, "public"],
        queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}/public`),
        enabled: setupId != null,
        retry: (count, error) =>
          error instanceof ApiError && error.status === 404 ? false : count < 3,
      });
    }
    ```

    The public endpoint returns the same shape as the private one (SetupWithItems) but with `isPublic` always `true` and the owner's category names included as read-only context per D-03.

    **4. Guard useOnboardingComplete** in `src/client/hooks/useSettings.ts` — Pitfall 2 from research. The `useSetting` hook calls an auth-gated endpoint. For unauthenticated users, it returns an error and `isLoading` may be `true` briefly, blocking render.

    Change `useOnboardingComplete` to accept an `enabled` parameter:
    ```typescript
    export function useOnboardingComplete(enabled = true) {
      return useQuery({
        queryKey: ["settings", "onboardingComplete"],
        queryFn: async () => {
          try {
            const result = await apiGet<Setting>(`/api/settings/onboardingComplete`);
            return result.value;
          } catch (err: any) {
            if (err?.status === 404) return null;
            throw err;
          }
        },
        enabled,
      });
    }
    ```

    This replaces the current delegation to `useSetting("onboardingComplete")` with a direct `useQuery` call that accepts an `enabled` parameter. The query logic is identical to `useSetting` — just inlined so `enabled` can be passed through.
  </action>
  <verify>
    <automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
  </verify>
  <acceptance_criteria>
    - uiStore.ts contains `showAuthPrompt: boolean` in the interface
    - uiStore.ts contains `openAuthPrompt: () => set({ showAuthPrompt: true })`
    - uiStore.ts contains `closeAuthPrompt: () => set({ showAuthPrompt: false })`
    - AuthPromptModal.tsx exists and contains `sign in or sign up`
    - AuthPromptModal.tsx contains `to="/login"` (both links point to /login)
    - AuthPromptModal.tsx contains `Create account` button text
    - AuthPromptModal.tsx contains `className="fixed inset-0 z-50`
    - useSetups.ts contains `export function usePublicSetup(`
    - useSetups.ts contains `/api/setups/${setupId}/public`
    - useSettings.ts `useOnboardingComplete` accepts `enabled` parameter
    - `bun run lint` exits 0
  </acceptance_criteria>
  <done>Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag</done>
</task>

<task type="auto">
  <name>Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages</name>
  <files>src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx</files>
  <read_first>
    - src/client/routes/__root.tsx (current auth loading spinner, redirect logic, isPublicRoute)
    - src/client/routes/global-items/$globalItemId.tsx (action buttons to guard)
    - src/client/routes/setups/$setupId.tsx (full file — need to understand write actions and data flow)
    - src/client/stores/uiStore.ts (after Task 1 — confirm showAuthPrompt exists)
    - src/client/components/AuthPromptModal.tsx (after Task 1 — confirm component exists)
    - src/client/hooks/useSetups.ts (after Task 1 — confirm usePublicSetup exists)
  </read_first>
  <action>
    **1. Rework __root.tsx** per D-04 and D-09:

    **a. Remove the authLoading spinner gate (lines 121-127).** Delete this entire block:
    ```typescript
    // REMOVE THIS:
    if (authLoading) {
      return (
        <div className="min-h-screen bg-gray-50 flex items-center justify-center">
          <div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
        </div>
      );
    }
    ```

    **b. Expand isPublicRoute** (replace current line 131-132):
    ```typescript
    const isPublicRoute =
      location.pathname === "/" ||
      location.pathname.startsWith("/users/") ||
      location.pathname.startsWith("/global-items") ||
      location.pathname.startsWith("/setups/") ||
      location.pathname === "/login";
    ```

    **c. Replace hard redirect** (replace lines 138-145). Remove `window.location.href = "/login"` and replace with soft redirect that only fires after auth resolves:
    ```typescript
    if (!isAuthenticated && !isPublicRoute && !authLoading) {
      navigate({ to: "/login" });
      return null;
    }
    ```

    **d. Remove onboarding loading spinner gate** (lines 147-154). Delete the entire `if (onboardingLoading)` block. The `showWizard` check already guards on `isAuthenticated`, so this gate is unnecessary. Update the `useOnboardingComplete` call to pass `enabled: isAuthenticated`:
    ```typescript
    const { data: onboardingComplete, isLoading: onboardingLoading } =
      useOnboardingComplete(isAuthenticated);
    ```

    **e. Add AuthPromptModal** to the return JSX. Import at top:
    ```typescript
    import { AuthPromptModal } from "../components/AuthPromptModal";
    ```
    Add inside the root `<div>`, after the `<Toaster>` and before the onboarding wizard:
    ```tsx
    {/* Auth Prompt Modal */}
    <AuthPromptModal />
    ```

    **2. Guard write actions in global-items/$globalItemId.tsx** per D-06 and PUBL-05:

    Add imports:
    ```typescript
    import { useAuth } from "../../hooks/useAuth";
    ```

    Inside the `GlobalItemDetail` component, add:
    ```typescript
    const { data: auth } = useAuth();
    const isAuthenticated = !!auth?.user;
    const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
    ```

    Replace the two button onClick handlers. For "Add to Collection":
    ```typescript
    onClick={() => {
      if (!isAuthenticated) {
        openAuthPrompt();
        return;
      }
      openAddToCollection(item.id, `${item.brand} ${item.model}`);
    }}
    ```

    For "Add to Thread":
    ```typescript
    onClick={() => {
      if (!isAuthenticated) {
        openAuthPrompt();
        return;
      }
      openAddToThread(item.id, `${item.brand} ${item.model}`);
    }}
    ```

    **3. Rework setups/$setupId.tsx** for anonymous viewing per PUBL-02:

    This is the most complex change. The current page calls `useSetup(id)` which hits the auth-gated `GET /api/setups/:id`. Anonymous visitors get a 401.

    Add imports:
    ```typescript
    import { useAuth } from "../../hooks/useAuth";
    import { usePublicSetup } from "../../hooks/useSetups";
    ```

    At the top of `SetupDetailPage`, add auth detection:
    ```typescript
    const { data: auth } = useAuth();
    const isAuthenticated = !!auth?.user;
    ```

    Change the data fetching to be conditional:
    ```typescript
    const privateSetup = useSetup(isAuthenticated ? numericId : null);
    const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
    const { data: setup, isLoading } = isAuthenticated
      ? privateSetup
      : publicSetup;
    ```

    Wrap all write action UI elements (Delete button, Add Items button, Public toggle, remove item buttons, classification dropdowns) in `isAuthenticated` guards:
    ```typescript
    {isAuthenticated && (
      <button onClick={() => setPickerOpen(true)}>Add Items</button>
    )}
    ```

    Apply this guard to:
    - The "Add Items" button
    - The "Delete Setup" button and its confirmation dialog
    - The "Public" toggle switch
    - The remove button on individual items (the X icon)
    - The classification dropdown on individual items
    - The `ItemPicker` component render

    The read-only display (setup name, items list, weight summary, totals) should render for everyone.

    Also: the mutation hooks (`useDeleteSetup`, `useUpdateSetup`, `useRemoveSetupItem`, `useUpdateItemClassification`) can remain — they just won't be invoked since their triggers are hidden. But `useDeleteSetup()` and `useUpdateSetup(numericId)` calls at the top of the component are fine to keep (they return mutation objects, no network call until `.mutate()` is called).
  </action>
  <verify>
    <automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
  </verify>
  <acceptance_criteria>
    - __root.tsx does NOT contain `if (authLoading)` followed by a spinner return
    - __root.tsx does NOT contain `window.location.href = "/login"`
    - __root.tsx contains `pathname === "/" ||` in isPublicRoute
    - __root.tsx contains `pathname.startsWith("/global-items")` in isPublicRoute
    - __root.tsx contains `pathname.startsWith("/setups/")` in isPublicRoute
    - __root.tsx contains `navigate({ to: "/login" })` (soft redirect for private routes)
    - __root.tsx contains `!authLoading` in the redirect condition
    - __root.tsx contains `<AuthPromptModal` in the JSX
    - __root.tsx contains `useOnboardingComplete(isAuthenticated)`
    - global-items/$globalItemId.tsx contains `openAuthPrompt` import from uiStore
    - global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToCollection
    - global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToThread
    - setups/$setupId.tsx contains `usePublicSetup` import
    - setups/$setupId.tsx contains `useAuth` import
    - setups/$setupId.tsx contains conditional `isAuthenticated ? privateSetup : publicSetup` or equivalent
    - setups/$setupId.tsx write action buttons wrapped in `{isAuthenticated &&` guards
    - `bun run lint` exits 0
  </acceptance_criteria>
  <done>Root layout renders immediately for anonymous visitors. Public routes include /, /global-items/*, /setups/*, /users/*, /login. Write actions on catalog detail show auth prompt. Setup detail page shows read-only view for anonymous visitors using the public API endpoint.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Verify public access flows</name>
  <what-built>
    Complete public access infrastructure: anonymous visitors can browse catalog, view public setups, and view profiles without logging in. Write actions show a friendly sign-in/sign-up prompt instead of redirecting.
  </what-built>
  <how-to-verify>
    1. Start dev server: `bun run dev`
    2. Open an incognito/private browser window (no session)
    3. Visit `http://localhost:5173/` — should see the app immediately (no spinner, no redirect to /login)
    4. Visit `http://localhost:5173/global-items` — catalog page loads with items
    5. Click on any catalog item — detail page loads with image, specs, action buttons
    6. Click "Add to Collection" — auth prompt modal appears with "sign in or sign up" message, two buttons (Sign in, Create account)
    7. Close the modal (click backdrop or press Escape)
    8. Click "Add to Thread" — same auth prompt modal appears
    9. Visit a public setup URL (e.g., `http://localhost:5173/setups/1` if a public setup exists) — setup renders with items and totals, no write action buttons visible
    10. Visit a user profile (e.g., `http://localhost:5173/users/1`) — profile page loads
    11. Verify top-right corner shows "Sign in" link (already existing in TotalsBar)
    12. Now log in normally — verify all write actions work as before (FAB appears, Add to Collection works, setup edit buttons appear)
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues</resume-signal>
</task>

</tasks>

<verification>
- Anonymous visitor can browse catalog without login (PUBL-01)
- Anonymous visitor can view public setups (PUBL-02)
- Anonymous visitor can view user profiles (PUBL-03)
- No auth spinner or redirect on first visit (PUBL-04)
- Write actions prompt sign-in instead of executing (PUBL-05)
- `bun run lint` passes
- `bun test` passes (no regressions)
</verification>

<success_criteria>
- Root layout renders immediately for anonymous visitors
- isPublicRoute includes /, /global-items/*, /setups/*, /users/*, /login
- AuthPromptModal shows friendly sign-in/sign-up prompt on write action attempts
- Setup detail page uses public API endpoint for anonymous visitors
- Catalog detail page guards both "Add to Collection" and "Add to Thread" buttons
- No hard redirects (window.location.href) remain in root layout
- Authenticated user experience is completely unchanged
</success_criteria>

<output>
After completion, create `.planning/phases/24-public-access-infrastructure/24-02-SUMMARY.md`
</output>
