diff --git a/docs/guest-mode.md b/docs/guest-mode.md
new file mode 100644
index 0000000..1d0121e
--- /dev/null
+++ b/docs/guest-mode.md
@@ -0,0 +1,162 @@
+# Guest Browsing Mode
+
+> Branch: `feat/guest-dashboard-access`
+
+## Overview
+
+PetAd supports a **guest browsing mode** that lets unauthenticated visitors explore public pages — including the homepage, pet listings, and listing detail pages — without requiring login. Interactive, state-changing actions (favouriting, expressing interest, creating listings, etc.) are **gated behind authentication** using a reusable interceptor pattern.
+
+---
+
+## Public Routes (Accessible to Guests)
+
+| Route | Component | Notes |
+|---|---|---|
+| `/` | → redirects to `/home` | Root redirect changed from `/login` to `/home` |
+| `/home` | `HomePage` | Pet listing section, hero, owner modal |
+| `/listings` | `ListingsPage` | Browse all available pets |
+| `/listings/:id` | `PetListingDetailsPage` | Pet details, gallery, owner info |
+
+All three routes are wrapped in `PublicRoute` — a passthrough outlet that performs no auth check.
+
+---
+
+## Private Routes (Authentication Required)
+
+All routes not listed above remain inside `ProtectedRoute` and redirect to `/login` for unauthenticated visitors:
+
+- `/profile`, `/favourites`, `/interests`
+- `/notifications`, `/notification-preferences`, `/settings/notifications`
+- `/list-for-adoption`, `/my-listings/:id`
+- `/adoption/:adoptionId/settlement|timeline`
+- `/admin/approvals`, `/admin/disputes`
+- `/shelter/approvals`
+- `/disputes`, `/disputes/:id`
+- `/custody/:custodyId/timeline`
+
+---
+
+## Authentication Gate (Interactive Actions)
+
+### `AuthGateProvider` & `useAuthGate`
+
+Wrap any subtree (currently the full app via `App.tsx`) with ``. This provides:
+
+```tsx
+const { requireAuth, replayAndClose, close, isOpen, reason, returnTo } = useAuthGate();
+```
+
+Call `requireAuth(reason, pendingAction?)` from any button to:
+1. Prevent the action from firing.
+2. Open the `AuthGateModal` with the reason text.
+3. Store the `returnTo` path and an optional pending-action closure.
+
+### `useAuthAction` Hook
+
+The simplest way to gate any button:
+
+```tsx
+import { useAuthAction } from '../hooks/useAuthAction';
+
+const handleFavourite = useAuthAction(
+ () => saveToFavourites(petId),
+ 'Sign in to save pets to your favourites list.'
+);
+
+
+```
+
+- If authenticated → runs the callback immediately.
+- If guest → opens `AuthGateModal` and stores the callback for potential replay.
+
+### `AuthGateModal`
+
+Rendered inside `MainLayout` so it overlays all pages. It shows:
+
+- A reason for why auth is required.
+- **"Sign In to Continue"** — redirects to `/login?returnTo=` and stores the pending action hint in `sessionStorage`.
+- **"Create Free Account"** — redirects to `/register?returnTo=`.
+- **"Continue browsing as guest"** — dismisses the modal.
+
+---
+
+## Return-to-Page After Login
+
+After the user signs in, `SignInForm` reads the `returnTo` destination from:
+
+1. **`?returnTo=` query parameter** (set by `AuthGateModal` redirect).
+2. **`sessionStorage.petad_return_to`** (fallback).
+
+The user is navigated back to the exact page they were viewing using `navigate(returnTo, { replace: true })`.
+
+### Pending Action Restoration
+
+Closures cannot be serialised across a full-page redirect. Instead:
+
+- `sessionStorage.petad_pending_action_hint` stores a human-readable label (the `reason` string).
+- After login the `SignInForm` shows a **"Sign in to continue: [action]"** banner confirming context.
+- If the `AuthGateModal` is still open (same-page flow, no redirect), call `replayAndClose()` to execute the captured closure immediately.
+
+---
+
+## Guest Banner
+
+`GuestBanner` is a subtle, dismissible sticky bar shown above the navbar to unauthenticated users:
+
+> *"You're browsing as a guest. **Sign in** to save favourites, express interest, and more."*
+
+- Hides automatically for authenticated users.
+- Links to `/login?returnTo=`.
+- Dismissible via the ✕ button.
+
+---
+
+## Backend / MSW Endpoint Authorization
+
+| Method | Endpoint | Auth Required | Notes |
+|---|---|---|---|
+| `GET` | `/api/listings` | ❌ No | Returns demo/public data |
+| `GET` | `/api/listings/:id` | ❌ No | Returns public listing |
+| `POST` | `/api/listings/:id/interest` | ✅ Yes | 401 if no Bearer token |
+| `POST` | `/api/listings/:id/favourite` | ✅ Yes | 401 if no Bearer token |
+| `POST` | `/api/listings` | ✅ Yes | Create listing — 401 if no token |
+| `PUT` | `/api/listings/:id` | ✅ Yes | Update listing — 401 if no token |
+| `DELETE` | `/api/listings/:id` | ✅ Yes | Delete listing — 401 if no token |
+
+The MSW handler checks for a `Authorization: Bearer ` header. On a real backend, replace with your JWT middleware applied to mutation routes only.
+
+---
+
+## File Reference
+
+| File | Purpose |
+|---|---|
+| `src/context/AuthGateContext.tsx` | Provider + `useAuthGate` hook, stores pending action + returnTo |
+| `src/hooks/useAuthAction.ts` | Reusable hook to gate any callback behind auth |
+| `src/components/auth/PublicRoute.tsx` | Passthrough route wrapper for guest-accessible pages |
+| `src/components/auth/AuthGateModal.tsx` | Intercept modal shown on unauthenticated actions |
+| `src/components/layout/GuestBanner.tsx` | Subtle sign-in nudge banner for guests |
+| `src/components/layout/MainLayout.tsx` | Renders `GuestBanner` + `AuthGateModal` at layout level |
+| `src/components/auth/SignInForm.tsx` | Reads `returnTo` + pending hint, restores navigation after login |
+| `src/mocks/handlers/listings.ts` | MSW: public GETs + protected mutations |
+| `src/App.tsx` | Updated routing with `PublicRoute` + `AuthGateProvider` |
+| `src/components/auth/__tests__/GuestMode.test.tsx` | Comprehensive test suite |
+
+---
+
+## Security Notes
+
+- No private or user-specific data is returned from public GET endpoints.
+- All mutation endpoints (`POST`, `PUT`, `PATCH`, `DELETE`) remain protected.
+- The `ProtectedRoute` guard is unchanged — all existing private routes still redirect to `/login`.
+- Guest browsing only exposes data that is already shown on the public listing pages (mock/demo data in development).
+
+---
+
+## Assumptions
+
+1. This is a frontend-only project; "backend changes" are implemented via MSW mocks. The auth-check pattern (`Authorization: Bearer`) mirrors what a real API would enforce.
+2. The `auth_token` in `localStorage` / `sessionStorage` is the sole source of authentication truth (as per the existing `useAuth` hook).
+3. `UserDashboardPage` is not currently reachable from any route; it has not been changed.
+4. Pending-action replay across a full-page redirect is provided as a UX hint only (the closure cannot be serialised); same-page replay via `replayAndClose()` is fully supported.
+5. The `GuestBanner` is shown on all `MainLayout`-wrapped pages (public and protected alike) for guests, since it dismisses itself and authenticated users never see it.
diff --git a/install-bootstrap.cjs b/install-bootstrap.cjs
new file mode 100644
index 0000000..f79bd98
--- /dev/null
+++ b/install-bootstrap.cjs
@@ -0,0 +1,45 @@
+const { execSync } = require('child_process');
+const path = require('path');
+const nodeExe = process.execPath;
+
+// Try to find npm bundled inside this node install
+const possibleNpm = [
+ path.join(path.dirname(nodeExe), 'npm'),
+ path.join(path.dirname(nodeExe), 'npm.cmd'),
+ path.join(path.dirname(nodeExe), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
+];
+
+const fs = require('fs');
+let npmFound = null;
+for (const p of possibleNpm) {
+ if (fs.existsSync(p)) { npmFound = p; break; }
+}
+
+if (npmFound) {
+ console.log('Found npm at:', npmFound);
+ execSync(`"${nodeExe}" "${npmFound}" install`, {
+ stdio: 'inherit',
+ cwd: 'C:/Users/opeid/PetAd-Frontend'
+ });
+} else {
+ // Try npx which ships with node
+ const npxPaths = [
+ path.join(path.dirname(nodeExe), 'npx'),
+ path.join(path.dirname(nodeExe), 'npx.cmd'),
+ ];
+ let npxFound = null;
+ for (const p of npxPaths) {
+ if (fs.existsSync(p)) { npxFound = p; break; }
+ }
+ if (npxFound) {
+ console.log('Found npx at:', npxFound);
+ execSync(`"${npxFound}" --yes pnpm install`, {
+ stdio: 'inherit',
+ cwd: 'C:/Users/opeid/PetAd-Frontend'
+ });
+ } else {
+ console.log('Neither npm nor npx found alongside node binary at:', path.dirname(nodeExe));
+ console.log('Files in node dir:', fs.readdirSync(path.dirname(nodeExe)));
+ process.exit(1);
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 38c2e6f..efe67af 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,8 @@ import { useNotificationDeepLink } from "./hooks/useNotificationDeepLink";
import { MainLayout } from "./components/layout/MainLayout";
import { GuestRoute } from "./components/auth/GuestRoute";
import { ProtectedRoute } from "./components/auth/ProtectedRoute";
+import { PublicRoute } from "./components/auth/PublicRoute";
+import { AuthGateProvider } from "./context/AuthGateContext";
import FavouritePage from "./pages/FavouritePage";
import HomePage from "./pages/HomePage";
import ListingsPage from "./pages/ListingsPage";
@@ -34,66 +36,87 @@ function App() {
useNotificationDeepLink();
return (
-
- } />
+ /**
+ * AuthGateProvider must wrap Routes so that useLocation() inside the
+ * provider reads the correct current pathname when requireAuth() is called.
+ */
+
+
+ {/* ── Root redirect ─────────────────────────────────────────────── */}
+ } />
- }>
- } />
- } />
- } />
- } />
-
+ {/* ── Auth pages (redirect to /home when already logged in) ─────── */}
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* ── PUBLIC browsing routes — accessible to guests ─────────────── */}
+ {/*
+ * PublicRoute renders the Outlet unconditionally (no auth check).
+ * Interactive actions inside these pages must use useAuthAction() or
+ * call requireAuth() directly to gate state-changing operations.
+ */}
+ }>
+ }>
+ } />
+ } />
+ } />
+
+
- }>
- }>
- } />
- } />
- } />
- } />
- } />
- }
- />
- }
- />
- } />
- } />
- } />
- } />
- }
- />
- }
- />
- } />
- } />
- }
- />
- } />
- } />
- }
- />
- } />
- }
- />
- } />
+ {/* ── PROTECTED routes — authenticated users only ───────────────── */}
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ }
+ />
+ } />
+ }
+ />
+ } />
+
-
- } />
-
+ {/* ── Catch-all ─────────────────────────────────────────────────── */}
+ } />
+
+
);
}
diff --git a/src/components/auth/AuthGateModal.tsx b/src/components/auth/AuthGateModal.tsx
new file mode 100644
index 0000000..0d0ecd0
--- /dev/null
+++ b/src/components/auth/AuthGateModal.tsx
@@ -0,0 +1,119 @@
+/**
+ * AuthGateModal
+ *
+ * A full-screen overlay that intercepts unauthenticated interactive actions.
+ * It:
+ * - Shows the reason the action requires auth.
+ * - Stores the return-to path and any pending action in localStorage so the
+ * SignInForm can restore them after a full-page redirect.
+ * - Offers a "Sign In" CTA that redirects to /login with ?returnTo= query.
+ * - Offers a "Create Account" secondary CTA.
+ * - Announces itself to screen-readers via role="dialog" / aria-modal.
+ */
+
+import { useNavigate } from 'react-router-dom';
+import { useAuthGate } from '../../context/AuthGateContext';
+import { LogIn, UserPlus, X, LockKeyhole } from 'lucide-react';
+
+export function AuthGateModal() {
+ const { isOpen, reason, returnTo, pendingAction, close } = useAuthGate();
+ const navigate = useNavigate();
+
+ if (!isOpen) return null;
+
+ const handleSignIn = () => {
+ // Persist state across the full-page navigation to /login
+ if (returnTo) {
+ sessionStorage.setItem('petad_return_to', returnTo);
+ }
+ if (pendingAction) {
+ // We cannot serialise a closure, so we store a flag so the page can
+ // show a "resuming action" hint after login.
+ sessionStorage.setItem('petad_pending_action_hint', reason);
+ }
+ close();
+ navigate(`/login?returnTo=${encodeURIComponent(returnTo)}`);
+ };
+
+ const handleRegister = () => {
+ if (returnTo) sessionStorage.setItem('petad_return_to', returnTo);
+ close();
+ navigate(`/register?returnTo=${encodeURIComponent(returnTo)}`);
+ };
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Panel */}
+
+ {/* Close */}
+
+
+ {/* Header */}
+
+
+
+
+
+ Sign In Required
+
+
+ {reason}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/auth/PublicRoute.tsx b/src/components/auth/PublicRoute.tsx
new file mode 100644
index 0000000..09a5ed9
--- /dev/null
+++ b/src/components/auth/PublicRoute.tsx
@@ -0,0 +1,21 @@
+/**
+ * PublicRoute
+ *
+ * A route wrapper for pages that are accessible to both authenticated and
+ * unauthenticated (guest) users.
+ *
+ * Unlike ProtectedRoute (which redirects to /login when unauthenticated) and
+ * GuestRoute (which redirects to /home when authenticated), PublicRoute simply
+ * renders the Outlet for everyone.
+ *
+ * Use this for:
+ * - Homepage (/home)
+ * - Listings (/listings, /listings/:id)
+ * - Dashboard-style pages showing public/demo data
+ */
+import { Outlet } from 'react-router-dom';
+
+/** Renders the nested route outlet for ALL visitors, authenticated or not. */
+export function PublicRoute() {
+ return ;
+}
diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx
index 213ce12..b4b5e05 100644
--- a/src/components/auth/SignInForm.tsx
+++ b/src/components/auth/SignInForm.tsx
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { FormInput, PasswordInput, GoogleButton, OrDivider, SubmitButton } from "./RegisterForm";
-import { useNavigate, Link } from "react-router-dom";
+import { useNavigate, Link, useSearchParams } from "react-router-dom";
import { authService } from "../../api/authService";
import { ApiError } from "../../lib/api-errors";
@@ -15,16 +15,27 @@ interface SignInFormErrors {
submit?: string;
}
+/** Read the returnTo target from the query-string, then sessionStorage fallback. */
+function getReturnTo(searchParams: URLSearchParams): string {
+ const fromQuery = searchParams.get("returnTo");
+ if (fromQuery) return decodeURIComponent(fromQuery);
+ return sessionStorage.getItem("petad_return_to") ?? "/home";
+}
+
export function SignInForm() {
const [formData, setFormData] = useState({
email: "",
password: "",
});
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
+ // Show a hint if the user was interrupted mid-action
+ const pendingActionHint = sessionStorage.getItem("petad_pending_action_hint");
+
const validate = (): boolean => {
const newErrors: SignInFormErrors = {};
@@ -63,7 +74,12 @@ export function SignInForm() {
password: formData.password,
});
localStorage.setItem("auth_token", response.token);
- navigate("/home");
+
+ const returnTo = getReturnTo(searchParams);
+ sessionStorage.removeItem("petad_return_to");
+ sessionStorage.removeItem("petad_pending_action_hint");
+
+ navigate(returnTo, { replace: true });
} catch (err: unknown) {
const message =
err instanceof ApiError && err.status === 401
@@ -87,6 +103,16 @@ export function SignInForm() {
Welcome Pet Lover!
+ {/* Pending-action hint banner */}
+ {pendingActionHint && (
+
+ Sign in to continue: {pendingActionHint}
+
+ )}
+
-
+
Forget Password?
@@ -137,7 +166,7 @@ export function SignInForm() {
- Don't have an account?{" "}
+ Don't have an account?{" "}
+ {ui}
+ ,
+ );
+}
+
+function renderWithAuthGate(
+ ui: React.ReactNode,
+ initialPath = '/home',
+) {
+ return render(
+
+
+ {ui}
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ clearStorage();
+ vi.restoreAllMocks();
+});
+
+// ─── 1. Public Route: accessible to guests ────────────────────────────────────
+
+describe('PublicRoute', () => {
+ it('renders the outlet when unauthenticated', () => {
+ renderWithRouter(
+
+ }>
+ Public Home
} />
+
+ ,
+ '/home',
+ );
+ expect(screen.getByText('Public Home')).toBeInTheDocument();
+ });
+
+ it('renders the outlet when authenticated', () => {
+ setToken();
+ renderWithRouter(
+
+ }>
+ Listings} />
+
+ ,
+ '/listings',
+ );
+ expect(screen.getByText('Listings')).toBeInTheDocument();
+ });
+});
+
+// ─── 2. ProtectedRoute: still blocks guests ───────────────────────────────────
+
+describe('ProtectedRoute – still blocks unauthenticated users', () => {
+ it('redirects to /login when no token is present', () => {
+ renderWithRouter(
+
+ }>
+ Profile} />
+
+ Login Page} />
+ ,
+ '/profile',
+ );
+ expect(screen.getByText('Login Page')).toBeInTheDocument();
+ expect(screen.queryByText('Profile')).not.toBeInTheDocument();
+ });
+
+ it('renders the outlet for authenticated users', () => {
+ setToken();
+ renderWithRouter(
+
+ }>
+ Profile} />
+
+ Login Page} />
+ ,
+ '/profile',
+ );
+ expect(screen.getByText('Profile')).toBeInTheDocument();
+ });
+});
+
+// ─── 3. GuestRoute: still redirects authenticated users ───────────────────────
+
+describe('GuestRoute – still redirects authenticated users away from /login', () => {
+ it('renders login page for unauthenticated users', () => {
+ renderWithRouter(
+
+ }>
+ Sign In} />
+
+ Home} />
+ ,
+ '/login',
+ );
+ expect(screen.getByText('Sign In')).toBeInTheDocument();
+ });
+
+ it('redirects to /home when already authenticated', () => {
+ setToken();
+ renderWithRouter(
+
+ }>
+ Sign In} />
+
+ Home} />
+ ,
+ '/login',
+ );
+ expect(screen.getByText('Home')).toBeInTheDocument();
+ expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
+ });
+});
+
+// ─── 4. AuthGateModal: shows when requireAuth is called ───────────────────────
+
+function TriggerButton({ reason }: { reason: string }) {
+ const { requireAuth } = useAuthGate();
+ return (
+
+ );
+}
+
+describe('AuthGateModal', () => {
+ it('is hidden by default', () => {
+ renderWithAuthGate();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('appears when requireAuth() is called', () => {
+ renderWithAuthGate();
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Sign In Required')).toBeInTheDocument();
+ expect(screen.getByText('Sign in to express interest')).toBeInTheDocument();
+ });
+
+ it('closes when the backdrop is clicked', () => {
+ renderWithAuthGate();
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ // Click the close button (X)
+ fireEvent.click(screen.getByLabelText('Close'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('closes when "Continue browsing as guest" is clicked', () => {
+ renderWithAuthGate();
+ fireEvent.click(screen.getByText('Trigger'));
+ fireEvent.click(screen.getByText('Continue browsing as guest'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('stores returnTo and pending-action hint in sessionStorage on Sign In click', async () => {
+ renderWithAuthGate(
+
+ }
+ />
+ Login} />
+ ,
+ '/listings/1',
+ );
+ fireEvent.click(screen.getByText('Trigger'));
+ fireEvent.click(screen.getByText('Sign In to Continue'));
+ await waitFor(() => {
+ expect(sessionStorage.getItem('petad_return_to')).toBe('/listings/1');
+ expect(sessionStorage.getItem('petad_pending_action_hint')).toBe('Sign in to save favourites.');
+ });
+ });
+
+ it('navigates to /login with ?returnTo when Sign In is clicked', async () => {
+ let capturedLocation = '';
+
+ function Inspector() {
+ const loc = window.location.href;
+ useEffect(() => {
+ capturedLocation = loc;
+ }, [loc]);
+ return Login Page - {loc}
;
+ }
+
+ renderWithAuthGate(
+
+ }
+ />
+ } />
+ ,
+ '/home',
+ );
+ fireEvent.click(screen.getByText('Trigger'));
+ fireEvent.click(screen.getByText('Sign In to Continue'));
+ await waitFor(() => {
+ expect(screen.getByText(/Login Page/)).toBeInTheDocument();
+ });
+ // Confirm the page rendered login route
+ expect(capturedLocation).toBeDefined();
+ });
+});
+
+// ─── 5. useAuthAction hook ────────────────────────────────────────────────────
+
+function ActionButton({
+ action,
+ reason,
+ label = 'Act',
+}: {
+ action: () => void;
+ reason: string;
+ label?: string;
+}) {
+ const guarded = useAuthAction(action, reason);
+ return ;
+}
+
+describe('useAuthAction', () => {
+ it('fires the callback directly when the user is authenticated', () => {
+ setToken();
+ const action = vi.fn();
+ renderWithAuthGate(
+ ,
+ );
+ fireEvent.click(screen.getByText('Act'));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('opens AuthGateModal instead of firing action when unauthenticated', () => {
+ const action = vi.fn();
+ renderWithAuthGate(
+ ,
+ );
+ fireEvent.click(screen.getByText('Act'));
+ expect(action).not.toHaveBeenCalled();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Sign in to export data.')).toBeInTheDocument();
+ });
+});
+
+// ─── 6. Return-to-page: pending action hint on login form ─────────────────────
+
+describe('SignInForm – pending action hint', () => {
+ beforeEach(() => {
+ sessionStorage.setItem('petad_pending_action_hint', 'Sign in to save favourites.');
+ });
+
+ it('shows the pending-action hint banner when sessionStorage has a hint', async () => {
+ const { SignInForm } = await import('../SignInForm');
+ renderWithRouter(
+
+ } />
+ Home} />
+ ,
+ '/login',
+ );
+ expect(
+ screen.getByText(/Sign in to continue:/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Sign in to save favourites.'),
+ ).toBeInTheDocument();
+ });
+});
+
+// ─── 7. MSW: public GET listings does not require auth ───────────────────────
+
+describe('MSW listings handler – public GET', () => {
+ it('GET /api/listings returns data without Authorization header', async () => {
+ const res = await fetch('/api/listings');
+ expect(res.ok).toBe(true);
+ const json = await res.json() as { data: unknown[] };
+ expect(Array.isArray(json.data)).toBe(true);
+ expect(json.data.length).toBeGreaterThan(0);
+ });
+
+ it('GET /api/listings/:id returns a listing without Authorization header', async () => {
+ const res = await fetch('/api/listings/1');
+ expect(res.ok).toBe(true);
+ const json = await res.json() as { data: { id: number } };
+ expect(json.data.id).toBe(1);
+ });
+});
+
+// ─── 8. MSW: mutation endpoints require auth ──────────────────────────────────
+
+describe('MSW listings handler – protected mutations', () => {
+ it('POST /api/listings/1/interest returns 401 without token', async () => {
+ const res = await fetch('/api/listings/1/interest', { method: 'POST' });
+ expect(res.status).toBe(401);
+ });
+
+ it('POST /api/listings/1/interest returns 200 with valid token', async () => {
+ const res = await fetch('/api/listings/1/interest', {
+ method: 'POST',
+ headers: { Authorization: 'Bearer test-token' },
+ });
+ expect(res.ok).toBe(true);
+ });
+
+ it('POST /api/listings/1/favourite returns 401 without token', async () => {
+ const res = await fetch('/api/listings/1/favourite', { method: 'POST' });
+ expect(res.status).toBe(401);
+ });
+
+ it('POST /api/listings returns 401 without token', async () => {
+ const res = await fetch('/api/listings', { method: 'POST' });
+ expect(res.status).toBe(401);
+ });
+
+ it('PUT /api/listings/1 returns 401 without token', async () => {
+ const res = await fetch('/api/listings/1', { method: 'PUT' });
+ expect(res.status).toBe(401);
+ });
+
+ it('DELETE /api/listings/1 returns 401 without token', async () => {
+ const res = await fetch('/api/listings/1', { method: 'DELETE' });
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts
index 36b4763..fb8eb91 100644
--- a/src/components/auth/index.ts
+++ b/src/components/auth/index.ts
@@ -3,3 +3,5 @@ export { RegisterForm, FormInput, PasswordInput, GoogleButton, OrDivider, Submit
export { SignInForm } from './SignInForm';
export { ProtectedRoute } from './ProtectedRoute';
export { GuestRoute } from './GuestRoute';
+export { PublicRoute } from './PublicRoute';
+export { AuthGateModal } from './AuthGateModal';
diff --git a/src/components/layout/GuestBanner.tsx b/src/components/layout/GuestBanner.tsx
new file mode 100644
index 0000000..c36ebbc
--- /dev/null
+++ b/src/components/layout/GuestBanner.tsx
@@ -0,0 +1,55 @@
+/**
+ * GuestBanner
+ *
+ * A subtle, dismissible sticky banner shown to unauthenticated users on public
+ * pages, encouraging them to sign in to save progress / access more features.
+ *
+ * Design tokens follow the existing PetAd palette:
+ * - #0D1B2A dark navy (primary)
+ * - #E84D2A accent orange-red
+ */
+
+import { useState } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { useAuth } from '../../hooks/useAuth';
+import { Sparkles, X } from 'lucide-react';
+
+export function GuestBanner() {
+ const { isAuthenticated } = useAuth();
+ const location = useLocation();
+ const [dismissed, setDismissed] = useState(false);
+
+ // Only render for guests on browseable public pages
+ if (isAuthenticated || dismissed) return null;
+
+ const returnTo = encodeURIComponent(location.pathname + location.search);
+
+ return (
+
+
+
+ You're browsing as a guest.{' '}
+
+ Sign in
+ {' '}
+ to save favourites, express interest, and more.
+
+
+
+
+ );
+}
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index c2a0d89..e6184cb 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -3,12 +3,15 @@ import { Outlet } from "react-router-dom";
import { Navbar } from "./Navbar";
import { Footer } from "./Footer";
import ApprovalBanner from "./ApprovalBanner";
+import { GuestBanner } from "./GuestBanner";
+import { AuthGateModal } from "../auth/AuthGateModal";
export function MainLayout({ children }: PropsWithChildren) {
return (
-
-
+ {/* Guest sign-in nudge — hidden for authenticated users */}
+
+
@@ -18,6 +21,9 @@ export function MainLayout({ children }: PropsWithChildren) {
+
+ {/* Auth-gate intercept modal — rendered at layout level so it overlays everything */}
+
);
}
\ No newline at end of file
diff --git a/src/context/AuthGateContext.tsx b/src/context/AuthGateContext.tsx
new file mode 100644
index 0000000..0698d3e
--- /dev/null
+++ b/src/context/AuthGateContext.tsx
@@ -0,0 +1,94 @@
+/**
+ * AuthGateContext
+ *
+ * Provides shared state for the guest-mode authentication gate:
+ * - isOpen whether the auth gate modal is visible
+ * - reason human-readable string explaining why auth is required
+ * - pendingAction optional callback to replay after successful login
+ * - returnTo route the user was on when the gate fired
+ *
+ * Usage
+ * - Wrap the app (or any subtree) with .
+ * - Call requireAuth(reason, pendingAction?) from any interactive button.
+ * - After login, call replayAndClose() to execute the pending action and dismiss.
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useLocation } from 'react-router-dom';
+
+interface AuthGateState {
+ isOpen: boolean;
+ reason: string;
+ returnTo: string;
+ pendingAction: (() => void) | null;
+}
+
+interface AuthGateContextValue extends AuthGateState {
+ /** Fire this from any button that needs authentication. */
+ requireAuth: (reason: string, pendingAction?: () => void) => void;
+ /** Call after successful login – replays pending action then closes. */
+ replayAndClose: () => void;
+ /** Dismiss the modal without replaying. */
+ close: () => void;
+}
+
+const AuthGateContext = createContext(null);
+
+export function AuthGateProvider({ children }: { children: ReactNode }) {
+ const location = useLocation();
+
+ const [state, setState] = useState({
+ isOpen: false,
+ reason: '',
+ returnTo: '/',
+ pendingAction: null,
+ });
+
+ const requireAuth = useCallback(
+ (reason: string, pendingAction?: () => void) => {
+ setState({
+ isOpen: true,
+ reason,
+ returnTo: location.pathname + location.search,
+ pendingAction: pendingAction ?? null,
+ });
+ },
+ [location.pathname, location.search],
+ );
+
+ const replayAndClose = useCallback(() => {
+ const action = state.pendingAction;
+ setState((prev) => ({ ...prev, isOpen: false, pendingAction: null }));
+ if (action) {
+ // small tick so the modal unmounts before the action runs
+ setTimeout(action, 50);
+ }
+ }, [state.pendingAction]);
+
+ const close = useCallback(() => {
+ setState((prev) => ({ ...prev, isOpen: false }));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** Must be used inside . */
+export function useAuthGate(): AuthGateContextValue {
+ const ctx = useContext(AuthGateContext);
+ if (!ctx) {
+ throw new Error('useAuthGate must be used inside ');
+ }
+ return ctx;
+}
diff --git a/src/hooks/useAuthAction.ts b/src/hooks/useAuthAction.ts
new file mode 100644
index 0000000..20e4e8f
--- /dev/null
+++ b/src/hooks/useAuthAction.ts
@@ -0,0 +1,49 @@
+/**
+ * useAuthAction
+ *
+ * Wraps any interactive callback so that unauthenticated users see the
+ * AuthGateModal instead of triggering the action directly.
+ *
+ * Usage:
+ * const handleFavourite = useAuthAction(
+ * () => saveToFavourites(petId),
+ * 'Sign in to save pets to your favourites list.'
+ * );
+ *
+ *
+ *
+ * If the user is already authenticated the callback fires immediately.
+ * If not, the AuthGateModal appears with `reason` as the explanation.
+ * After login the user is returned to the current page; the pending action
+ * cannot be automatically replayed after a full-page redirect (sessionStorage
+ * carries a hint so the UI can show a "resuming…" message), but it CAN be
+ * replayed when the modal is dismissed with useAuthGate().replayAndClose().
+ */
+
+import { useCallback } from 'react';
+import { useAuth } from './useAuth';
+import { useAuthGate } from '../context/AuthGateContext';
+
+/**
+ * @param action The function to execute when the user is authenticated.
+ * @param reason Human-readable text shown in the AuthGateModal.
+ */
+export function useAuthAction(
+ action: (...args: TArgs) => void,
+ reason: string,
+): (...args: TArgs) => void {
+ const { isAuthenticated } = useAuth();
+ const { requireAuth } = useAuthGate();
+
+ return useCallback(
+ (...args: TArgs) => {
+ if (isAuthenticated) {
+ action(...args);
+ } else {
+ requireAuth(reason, () => action(...args));
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [isAuthenticated, requireAuth, reason],
+ );
+}
diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts
index c4a2bc6..2198f96 100644
--- a/src/mocks/handlers/index.ts
+++ b/src/mocks/handlers/index.ts
@@ -7,6 +7,7 @@ import { filesHandlers } from "./files";
import { adoptionHandlers } from "./adoption";
import { custodyHandlers } from "./custody";
import { authHandlers } from "./auth";
+import { listingsHandlers } from "./listings";
/**
* All MSW request handlers, combined from every domain module.
@@ -22,4 +23,5 @@ export const handlers = [
...filesHandlers,
...adoptionHandlers,
...custodyHandlers,
+ ...listingsHandlers,
];
diff --git a/src/mocks/handlers/listings.ts b/src/mocks/handlers/listings.ts
new file mode 100644
index 0000000..3b0c0c5
--- /dev/null
+++ b/src/mocks/handlers/listings.ts
@@ -0,0 +1,121 @@
+/**
+ * listings.ts — MSW handlers for pet listing endpoints.
+ *
+ * Public GET endpoints: no Authorization header required.
+ * Mutation endpoints: return 401 when Authorization header is absent.
+ */
+
+import { http, HttpResponse } from "msw";
+
+const DEMO_LISTINGS = [
+ {
+ id: 1,
+ name: "Pet For Adoption",
+ species: "Dog",
+ breed: "German Shepherd",
+ age: "4yrs old",
+ status: "Available",
+ interests: 4,
+ imageUrl: "/assets/dog.png",
+ isPublic: true,
+ },
+ {
+ id: 2,
+ name: "Pet For Adoption",
+ species: "Dog",
+ breed: "German Shepherd",
+ age: "4yrs old",
+ status: "Pending Consent",
+ interests: 2,
+ imageUrl: "/assets/dog_1.png",
+ isPublic: true,
+ },
+ {
+ id: 3,
+ name: "Pet For Adoption",
+ species: "Dog",
+ breed: "Golden Retriever",
+ age: "3yrs old",
+ status: "Available",
+ interests: 1,
+ imageUrl: "/assets/golden_retriever.png",
+ isPublic: true,
+ },
+];
+
+/** Returns true when the request carries a valid Bearer token. */
+function isAuthorized(request: Request): boolean {
+ const auth = request.headers.get("Authorization") ?? "";
+ return auth.startsWith("Bearer ") && auth.length > 7;
+}
+
+export const listingsHandlers = [
+ // ── PUBLIC: list all pet listings (no auth required) ─────────────────────
+ http.get("/api/listings", () => {
+ return HttpResponse.json({ data: DEMO_LISTINGS });
+ }),
+
+ // ── PUBLIC: get a single pet listing (no auth required) ──────────────────
+ http.get("/api/listings/:id", ({ params }) => {
+ const listing = DEMO_LISTINGS.find((l) => l.id === Number(params.id));
+ if (!listing) {
+ return HttpResponse.json({ message: "Listing not found" }, { status: 404 });
+ }
+ return HttpResponse.json({ data: listing });
+ }),
+
+ // ── PROTECTED: express interest (auth required) ───────────────────────────
+ http.post("/api/listings/:id/interest", ({ request }) => {
+ if (!isAuthorized(request)) {
+ return HttpResponse.json(
+ { message: "Authentication required to express interest.", code: "UNAUTHORIZED" },
+ { status: 401 },
+ );
+ }
+ return HttpResponse.json({ success: true });
+ }),
+
+ // ── PROTECTED: add to favourites (auth required) ─────────────────────────
+ http.post("/api/listings/:id/favourite", ({ request }) => {
+ if (!isAuthorized(request)) {
+ return HttpResponse.json(
+ { message: "Authentication required to save favourites.", code: "UNAUTHORIZED" },
+ { status: 401 },
+ );
+ }
+ return HttpResponse.json({ success: true });
+ }),
+
+ // ── PROTECTED: create a new listing (auth required) ──────────────────────
+ http.post("/api/listings", ({ request }) => {
+ if (!isAuthorized(request)) {
+ return HttpResponse.json(
+ { message: "Authentication required to create a listing.", code: "UNAUTHORIZED" },
+ { status: 401 },
+ );
+ }
+ return HttpResponse.json({ success: true, id: 99 }, { status: 201 });
+ }),
+
+ // ── PROTECTED: update a listing (auth required) ───────────────────────────
+ http.put("/api/listings/:id", ({ request }) => {
+ if (!isAuthorized(request)) {
+ return HttpResponse.json(
+ { message: "Authentication required to update a listing.", code: "UNAUTHORIZED" },
+ { status: 401 },
+ );
+ }
+ return HttpResponse.json({ success: true });
+ }),
+
+ // ── PROTECTED: delete a listing (auth required) ───────────────────────────
+ http.delete("/api/listings/:id", ({ request }) => {
+ if (!isAuthorized(request)) {
+ return HttpResponse.json(
+ { message: "Authentication required to delete a listing.", code: "UNAUTHORIZED" },
+ { status: 401 },
+ );
+ }
+ return HttpResponse.json({ success: true });
+ }),
+];