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 */} + + ); +} 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 }); + }), +];