diff --git a/.env b/.env
new file mode 100644
index 0000000..ae69d5d
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+VITE_MSW=true
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index a3f4e21..6ae4904 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import RegisterPage from "./pages/RegisterPage";
import ForgetPasswordPage from "./pages/forgetPasswordPage";
import InterestPage from "./pages/interestPage";
import NotificationPage from "./pages/notificationPage";
+import NotificationPreferencesPage from "./pages/NotificationPreferencesPage";
import ResetPasswordPage from "./pages/resetPasswordPage";
import { AdoptionCompletionDemo } from "./pages/AdoptionCompletionDemo";
import PetListingDetailsPage from "./pages/PetlistingdetailsPage";
@@ -19,10 +20,9 @@ import AdoptionTimelinePage from "./pages/AdoptionTimelinePage";
import ModalPreview from "./pages/ModalPreview";
import StatusPollingDemo from "./pages/StatusPollingDemo";
import CustodyTimelinePage from "./pages/CustodyTimelinePage";
-import AdminDisputeListPage from "./pages/AdminDisputeListPage";
+import AdminApprovalQueuePage from "./pages/AdminApprovalQueuePage";
function App() {
-
return (
{/* Auth Routes - No Navbar/Footer */}
@@ -32,6 +32,7 @@ function App() {
} />
} />
+ {/* Main App Routes - With Navbar/Footer */}
}>
} />
} />
@@ -42,22 +43,37 @@ function App() {
} />
} />
} />
- } />
- } />
-
- } />
-
- {/* Admin Routes */}
- } />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
{/* Preview Routes */}
} />
- } />
+ }
+ />
} />
);
-
}
-export default App;
\ No newline at end of file
+export default App;
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index 2428f36..0fd9be2 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -1,9 +1,9 @@
-import { Outlet } from "react-router-dom";
+import { type PropsWithChildren } from "react";
import { Navbar } from "./Navbar";
import { Footer } from "./Footer";
import ApprovalBanner from "./ApprovalBanner";
-export function MainLayout() {
+export function MainLayout({ children }: PropsWithChildren) {
return (
@@ -13,7 +13,7 @@ export function MainLayout() {
-
+ {children}
diff --git a/src/components/modals/ConfirmationModal.tsx b/src/components/modals/ConfirmationModal.tsx
new file mode 100644
index 0000000..222b1a0
--- /dev/null
+++ b/src/components/modals/ConfirmationModal.tsx
@@ -0,0 +1,75 @@
+interface ConfirmationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ isLoading?: boolean;
+}
+
+export function ConfirmationModal({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel = "Confirm",
+ cancelLabel = "Cancel",
+ isLoading = false,
+}: ConfirmationModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="confirmation-title"
+ >
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+ {cancelLabel}
+
+
+ {isLoading ? "Loading..." : confirmLabel}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/modals/index.ts b/src/components/modals/index.ts
index 47b0f73..1ee4261 100644
--- a/src/components/modals/index.ts
+++ b/src/components/modals/index.ts
@@ -1,6 +1,9 @@
export { StartAdoptionModal } from "./StartAdoptionModal";
export { AdoptionCompletionModal } from "./AdoptionCompletionModal";
+export { RejectionReasonModal } from "./RejectionReasonModal";
+export { AdminDisputeResolutionForm } from "./AdminDisputeResolutionForm";
+export { ConfirmationModal } from "./ConfirmationModal";
export { DocumentUploadModal } from "./DocumentUploadModal";
export type { CompletionFormData } from "./StartAdoptionModal";
export type { AdoptionCompletionData } from "./AdoptionCompletionModal";
-export type { AdminDisputeResolutionFormData } from "./AdminDisputeResolutionForm";
+export type { AdminDisputeResolutionFormData } from "./AdminDisputeResolutionForm";
\ No newline at end of file
diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx
new file mode 100644
index 0000000..95aaa8e
--- /dev/null
+++ b/src/components/ui/ToggleSwitch.tsx
@@ -0,0 +1,26 @@
+interface ToggleSwitchProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ label: string;
+ disabled?: boolean;
+}
+
+export function ToggleSwitch({ checked, onChange, label, disabled }: ToggleSwitchProps) {
+ return (
+
!disabled && onChange(!checked)}
+ >
+
+
{label}
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/__tests__/useMutateUpdatePreferences.test.tsx b/src/hooks/__tests__/useMutateUpdatePreferences.test.tsx
new file mode 100644
index 0000000..3c864f8
--- /dev/null
+++ b/src/hooks/__tests__/useMutateUpdatePreferences.test.tsx
@@ -0,0 +1,98 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { getApiClient } from "../../lib/api-client";
+import { useMutateUpdatePreferences } from "../useMutateUpdatePreferences";
+import type { NotificationPreferences } from "../../types/notifications";
+
+vi.mock("../../lib/api-client", () => ({
+ getApiClient: vi.fn(),
+}));
+
+const mockApiClient = {
+ patch: vi.fn(),
+};
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
{children}
+ );
+
+ return { queryClient, wrapper };
+}
+
+const initialPreferences: NotificationPreferences = {
+ APPROVAL_REQUESTED: true,
+ ESCROW_FUNDED: true,
+ DISPUTE_RAISED: true,
+ SETTLEMENT_COMPLETE: true,
+ DOCUMENT_EXPIRING: true,
+ CUSTODY_EXPIRING: true,
+};
+
+const updatedPreferences: NotificationPreferences = {
+ ...initialPreferences,
+ APPROVAL_REQUESTED: false,
+};
+
+describe("useMutateUpdatePreferences", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getApiClient).mockReturnValue(mockApiClient as never);
+ });
+
+ it("calls the API with the updated preferences payload", async () => {
+ mockApiClient.patch.mockResolvedValue({});
+
+ const { wrapper } = createWrapper();
+ const { result } = renderHook(() => useMutateUpdatePreferences(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync(updatedPreferences);
+ });
+
+ expect(mockApiClient.patch).toHaveBeenCalledWith(
+ "/notifications/preferences",
+ updatedPreferences
+ );
+ });
+
+ it("optimistically updates and rolls back on error", async () => {
+ let rejectPatch: ((reason?: unknown) => void) | undefined;
+ mockApiClient.patch.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPatch = reject;
+ })
+ );
+
+ const { queryClient, wrapper } = createWrapper();
+ queryClient.setQueryData(["notificationPreferences"], initialPreferences);
+
+ const { result } = renderHook(() => useMutateUpdatePreferences(), { wrapper });
+
+ act(() => {
+ result.current.mutate(updatedPreferences);
+ });
+
+ await waitFor(() => {
+ expect(queryClient.getQueryData(["notificationPreferences"])).toEqual(updatedPreferences);
+ });
+
+ act(() => {
+ rejectPatch?.(new Error("network error"));
+ });
+
+ await waitFor(() => {
+ expect(queryClient.getQueryData(["notificationPreferences"])).toEqual(initialPreferences);
+ });
+ });
+});
diff --git a/src/hooks/useMutateUpdatePreferences.ts b/src/hooks/useMutateUpdatePreferences.ts
new file mode 100644
index 0000000..35e30a6
--- /dev/null
+++ b/src/hooks/useMutateUpdatePreferences.ts
@@ -0,0 +1,41 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { getApiClient } from "../lib/api-client";
+import type { NotificationPreferences } from "../types/notifications";
+
+export const useMutateUpdatePreferences = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (preferences: NotificationPreferences) => {
+ return getApiClient().patch("/notifications/preferences", preferences);
+ },
+ onMutate: async (newPreferences) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({ queryKey: ["notificationPreferences"] });
+
+ // Snapshot the previous value
+ const previousPreferences = queryClient.getQueryData
([
+ "notificationPreferences",
+ ]);
+
+ // Optimistically update to the new value
+ queryClient.setQueryData(["notificationPreferences"], newPreferences);
+
+ // Return a context object with the snapshotted value
+ return { previousPreferences };
+ },
+ onError: (_err, _newPreferences, context) => {
+ // If the mutation fails, use the context returned from onMutate to roll back
+ if (context?.previousPreferences) {
+ queryClient.setQueryData(
+ ["notificationPreferences"],
+ context.previousPreferences
+ );
+ }
+ },
+ onSettled: () => {
+ // Always refetch after error or success
+ queryClient.invalidateQueries({ queryKey: ["notificationPreferences"] });
+ },
+ });
+};
\ No newline at end of file
diff --git a/src/hooks/useNotificationPreferences.ts b/src/hooks/useNotificationPreferences.ts
new file mode 100644
index 0000000..c770894
--- /dev/null
+++ b/src/hooks/useNotificationPreferences.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { getApiClient } from "../lib/api-client";
+import type { NotificationPreferences } from "../types/notifications";
+
+export const useNotificationPreferences = () => {
+ return useQuery({
+ queryKey: ["notificationPreferences"],
+ queryFn: async (): Promise => {
+ return getApiClient().get("/notifications/preferences");
+ },
+ });
+};
\ No newline at end of file
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 2cb6fe6..1cb24ad 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -207,8 +207,11 @@ export function getApiClient(): ApiClient {
return apiClientInstance;
}
+const isMockServiceWorkerEnabled = import.meta.env.VITE_MSW === "true";
+const defaultApiUrl = isMockServiceWorkerEnabled ? "/api" : "http://localhost:3000/api";
+
// Vite environment variable
-const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000/api";
+const API_URL = import.meta.env.VITE_API_URL ?? defaultApiUrl;
export const apiClient = createApiClient({
baseURL: API_URL,
diff --git a/src/main.tsx b/src/main.tsx
index dbfee2f..8544015 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,10 +6,59 @@ import { queryClient } from './lib/query-client'
import './index.css'
import App from './App.tsx'
+const STALE_SW_RELOAD_KEY = 'petad:stale-sw-reload'
+
+async function cleanupServiceWorkers() {
+ if (!('serviceWorker' in navigator)) {
+ return false
+ }
+
+ const registrations = await navigator.serviceWorker.getRegistrations()
+ let removedStaleWorker = false
+
+ for (const registration of registrations) {
+ const scriptUrl =
+ registration.active?.scriptURL ??
+ registration.waiting?.scriptURL ??
+ registration.installing?.scriptURL
+
+ if (!scriptUrl) {
+ continue
+ }
+
+ const isMswWorker = scriptUrl.endsWith('/mockServiceWorker.js')
+
+ if (!isMswWorker) {
+ await registration.unregister()
+ removedStaleWorker = true
+ }
+ }
+
+ if (removedStaleWorker && !sessionStorage.getItem(STALE_SW_RELOAD_KEY)) {
+ sessionStorage.setItem(STALE_SW_RELOAD_KEY, 'true')
+ window.location.reload()
+ return true
+ }
+
+ sessionStorage.removeItem(STALE_SW_RELOAD_KEY)
+ return false
+}
+
async function bootstrap() {
+ const reloadingAfterCleanup = await cleanupServiceWorkers()
+
+ if (reloadingAfterCleanup) {
+ return
+ }
+
if (import.meta.env.VITE_MSW === 'true') {
const { worker } = await import('./mocks/browser')
- await worker.start({ onUnhandledRequest: 'warn' })
+ await worker.start({
+ onUnhandledRequest: 'warn',
+ serviceWorker: {
+ url: '/mockServiceWorker.js',
+ },
+ })
}
createRoot(document.getElementById('root')!).render(
diff --git a/src/mocks/handlers/notify.ts b/src/mocks/handlers/notify.ts
index dfe6f53..b0437e1 100644
--- a/src/mocks/handlers/notify.ts
+++ b/src/mocks/handlers/notify.ts
@@ -1,131 +1,167 @@
-// TODO: No backend model yet — align field names when Notification is added to Prisma schema.
-import { http, HttpResponse, delay } from "msw";
-import type { Notification, NotificationFilter, NotificationsPage, NotificationType } from "../../types/notifications";
+// TODO: No backend model yet - align field names when Notification is added to Prisma schema.
+import { delay, http, HttpResponse } from "msw";
+import type {
+ Notification,
+ NotificationFilter,
+ NotificationPreferences,
+ NotificationsPage,
+ NotificationType,
+} from "../../types/notifications";
-const _today = new Date();
-const _yesterday = new Date(_today);
-_yesterday.setDate(_yesterday.getDate() - 1);
-const _earlier = new Date(_today);
-_earlier.setDate(_earlier.getDate() - 3);
+const today = new Date();
+const yesterday = new Date(today);
+yesterday.setDate(yesterday.getDate() - 1);
+const earlier = new Date(today);
+earlier.setDate(earlier.getDate() - 3);
function iso(base: Date, hours: number): string {
- const d = new Date(base);
- d.setHours(hours, 0, 0, 0);
- return d.toISOString();
+ const date = new Date(base);
+ date.setHours(hours, 0, 0, 0);
+ return date.toISOString();
}
-let MOCK_NOTIFICATIONS: Notification[] = [
- {
- id: "notif-001",
- type: "ESCROW_FUNDED" as NotificationType,
- title: "Escrow Funded",
- message: "The escrow for adoption #adoption-001 has been funded and is ready.",
- time: iso(_today, 10),
- isRead: false,
- hasArrow: true,
- metadata: { resourceId: "adoption-001" },
- },
- {
- id: "notif-002",
- type: "APPROVAL_REQUESTED" as NotificationType,
- title: "Approval Requested",
- message: "A new approval request is waiting for your review on adoption #adoption-002.",
- time: iso(_today, 11),
- isRead: false,
- hasArrow: true,
- metadata: { resourceId: "adoption-002" },
- },
- {
- id: "notif-003",
- type: "DISPUTE_RAISED" as NotificationType,
- title: "Dispute Raised",
- message: "A dispute has been raised on adoption #adoption-002. Please review.",
- time: iso(_yesterday, 14),
- isRead: false,
- hasArrow: true,
- metadata: { resourceId: "dispute-001" },
- },
- {
- id: "notif-004",
- type: "DOCUMENT_EXPIRING" as NotificationType,
- title: "Document Expiring Soon",
- message: "The vaccination certificate for adoption #adoption-001 expires in 7 days.",
- time: iso(_yesterday, 8),
- isRead: true,
- hasArrow: false,
- metadata: { resourceId: "adoption-001" },
- },
- {
- id: "notif-005",
- type: "SETTLEMENT_COMPLETE" as NotificationType,
- title: "Settlement Complete",
- message: "The settlement for adoption #adoption-003 has been completed successfully.",
- time: iso(_earlier, 9),
- isRead: true,
- hasArrow: true,
- metadata: { resourceId: "adoption-003" },
- },
- {
- id: "notif-006",
- type: "CUSTODY_EXPIRING" as NotificationType,
- title: "Custody Period Expiring",
- message: "The temporary custody period for pet #pet-001 expires in 2 days.",
- time: iso(_earlier, 15),
- isRead: false,
- hasArrow: true,
- metadata: { resourceId: "pet-001" },
- },
+let mockNotifications: Notification[] = [
+ {
+ id: "notif-001",
+ type: "ESCROW_FUNDED" as NotificationType,
+ title: "Escrow Funded",
+ message: "The escrow for adoption #adoption-001 has been funded and is ready.",
+ time: iso(today, 10),
+ isRead: false,
+ hasArrow: true,
+ metadata: { resourceId: "adoption-001" },
+ },
+ {
+ id: "notif-002",
+ type: "APPROVAL_REQUESTED" as NotificationType,
+ title: "Approval Requested",
+ message: "A new approval request is waiting for your review on adoption #adoption-002.",
+ time: iso(today, 11),
+ isRead: false,
+ hasArrow: true,
+ metadata: { resourceId: "adoption-002" },
+ },
+ {
+ id: "notif-003",
+ type: "DISPUTE_RAISED" as NotificationType,
+ title: "Dispute Raised",
+ message: "A dispute has been raised on adoption #adoption-002. Please review.",
+ time: iso(yesterday, 14),
+ isRead: false,
+ hasArrow: true,
+ metadata: { resourceId: "dispute-001" },
+ },
+ {
+ id: "notif-004",
+ type: "DOCUMENT_EXPIRING" as NotificationType,
+ title: "Document Expiring Soon",
+ message: "The vaccination certificate for adoption #adoption-001 expires in 7 days.",
+ time: iso(yesterday, 8),
+ isRead: true,
+ hasArrow: false,
+ metadata: { resourceId: "adoption-001" },
+ },
+ {
+ id: "notif-005",
+ type: "SETTLEMENT_COMPLETE" as NotificationType,
+ title: "Settlement Complete",
+ message: "The settlement for adoption #adoption-003 has been completed successfully.",
+ time: iso(earlier, 9),
+ isRead: true,
+ hasArrow: true,
+ metadata: { resourceId: "adoption-003" },
+ },
+ {
+ id: "notif-006",
+ type: "CUSTODY_EXPIRING" as NotificationType,
+ title: "Custody Period Expiring",
+ message: "The temporary custody period for pet #pet-001 expires in 2 days.",
+ time: iso(earlier, 15),
+ isRead: false,
+ hasArrow: true,
+ metadata: { resourceId: "pet-001" },
+ },
];
+const mockNotificationPreferences: NotificationPreferences = {
+ APPROVAL_REQUESTED: true,
+ ESCROW_FUNDED: true,
+ DISPUTE_RAISED: true,
+ SETTLEMENT_COMPLETE: true,
+ DOCUMENT_EXPIRING: true,
+ CUSTODY_EXPIRING: true,
+};
+
const PAGE_SIZE = 10;
function getDelay(request: Request): number {
- return Number(new URL(request.url).searchParams.get("delay") ?? 0);
+ return Number(new URL(request.url).searchParams.get("delay") ?? 0);
}
-function applyFilter(list: Notification[], filter: NotificationFilter): Notification[] {
- if (filter === "unread") return list.filter((n) => !n.isRead);
- if (filter === "read") return list.filter((n) => n.isRead);
- return list;
+function applyFilter(
+ list: Notification[],
+ filter: NotificationFilter,
+): Notification[] {
+ if (filter === "unread") return list.filter((notification) => !notification.isRead);
+ if (filter === "read") return list.filter((notification) => notification.isRead);
+ return list;
}
export const notifyHandlers = [
- http.get("/api/notifications", async ({ request }) => {
- await delay(getDelay(request));
- const url = new URL(request.url);
- const cursor = url.searchParams.get("cursor") ?? null;
- const filter = (url.searchParams.get("filter") ?? "all") as NotificationFilter;
- const limit = Number(url.searchParams.get("limit") ?? PAGE_SIZE);
- const sorted = [...MOCK_NOTIFICATIONS].sort(
- (a, b) => new Date(b.time).getTime() - new Date(a.time).getTime(),
- );
- const filtered = applyFilter(sorted, filter);
- const startIndex = cursor
- ? filtered.findIndex((n) => String(n.id) === String(cursor)) + 1
- : 0;
- const page = filtered.slice(startIndex, startIndex + limit);
- const lastItem = page[page.length - 1];
- const hasMore = startIndex + limit < filtered.length;
- const response: NotificationsPage = {
- data: page,
- nextCursor: hasMore && lastItem ? String(lastItem.id) : null,
- total: filtered.length,
- };
- return HttpResponse.json(response);
- }),
+ http.get("/api/notifications", async ({ request }) => {
+ await delay(getDelay(request));
+ const url = new URL(request.url);
+ const cursor = url.searchParams.get("cursor") ?? null;
+ const filter = (url.searchParams.get("filter") ?? "all") as NotificationFilter;
+ const limit = Number(url.searchParams.get("limit") ?? PAGE_SIZE);
+ const sorted = [...mockNotifications].sort(
+ (a, b) => new Date(b.time).getTime() - new Date(a.time).getTime(),
+ );
+ const filtered = applyFilter(sorted, filter);
+ const startIndex = cursor
+ ? filtered.findIndex((notification) => String(notification.id) === String(cursor)) + 1
+ : 0;
+ const page = filtered.slice(startIndex, startIndex + limit);
+ const lastItem = page[page.length - 1];
+ const hasMore = startIndex + limit < filtered.length;
+ const response: NotificationsPage = {
+ data: page,
+ nextCursor: hasMore && lastItem ? String(lastItem.id) : null,
+ total: filtered.length,
+ };
+
+ return HttpResponse.json(response);
+ }),
+
+ http.patch("/api/notifications/:id/read", async ({ params, request }) => {
+ await delay(getDelay(request));
+ const { id } = params;
+ mockNotifications = mockNotifications.map((notification) =>
+ String(notification.id) === String(id)
+ ? { ...notification, isRead: true }
+ : notification,
+ );
+ return new HttpResponse(null, { status: 204 });
+ }),
- http.patch("/api/notifications/:id/read", async ({ request, params }) => {
- await delay(getDelay(request));
- const { id } = params;
- MOCK_NOTIFICATIONS = MOCK_NOTIFICATIONS.map((n) =>
- String(n.id) === String(id) ? { ...n, isRead: true } : n,
- );
- return new HttpResponse(null, { status: 204 });
- }),
+ http.post("/api/notifications/read-all", async ({ request }) => {
+ await delay(getDelay(request));
+ mockNotifications = mockNotifications.map((notification) => ({
+ ...notification,
+ isRead: true,
+ }));
+ return new HttpResponse(null, { status: 204 });
+ }),
- http.post("/api/notifications/read-all", async ({ request }) => {
- await delay(getDelay(request));
- MOCK_NOTIFICATIONS = MOCK_NOTIFICATIONS.map((n) => ({ ...n, isRead: true }));
- return new HttpResponse(null, { status: 204 });
- }),
-];
\ No newline at end of file
+ // GET /api/notifications/preferences - get notification preferences
+ http.get("**/api/notifications/preferences", async ({ request }) => {
+ await delay(getDelay(request));
+ return HttpResponse.json(mockNotificationPreferences);
+ }),
+
+ // PATCH /api/notifications/preferences - update notification preferences
+ http.patch("**/api/notifications/preferences", async ({ request }) => {
+ await delay(getDelay(request));
+ return new HttpResponse(null, { status: 204 });
+ }),
+];
diff --git a/src/pages/NotificationPreferencesPage.tsx b/src/pages/NotificationPreferencesPage.tsx
new file mode 100644
index 0000000..2dd9d28
--- /dev/null
+++ b/src/pages/NotificationPreferencesPage.tsx
@@ -0,0 +1,156 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useNotificationPreferences } from "../hooks/useNotificationPreferences";
+import { useMutateUpdatePreferences } from "../hooks/useMutateUpdatePreferences";
+import { ToggleSwitch } from "../components/ui/ToggleSwitch";
+import { ConfirmationModal } from "../components/modals/ConfirmationModal";
+import type { NotificationPreferences } from "../types/notifications";
+import { DEFAULT_NOTIFICATION_PREFERENCES } from "../types/notifications";
+
+const NOTIFICATION_LABELS: Record = {
+ APPROVAL_REQUESTED: "Approval Requested",
+ ESCROW_FUNDED: "Escrow Funded",
+ DISPUTE_RAISED: "Dispute Raised",
+ SETTLEMENT_COMPLETE: "Settlement Complete",
+ DOCUMENT_EXPIRING: "Document Expiring",
+ CUSTODY_EXPIRING: "Custody Expiring",
+};
+
+export default function NotificationPreferencesPage() {
+ const { data: preferences, isLoading } = useNotificationPreferences();
+ const updatePreferences = useMutateUpdatePreferences();
+ const [localPreferences, setLocalPreferences] = useState(null);
+ const [showResetModal, setShowResetModal] = useState(false);
+ const [showSaved, setShowSaved] = useState(false);
+ const debounceTimerRef = useRef(null);
+ const savedTimerRef = useRef(null);
+
+ useEffect(() => {
+ if (preferences) {
+ setLocalPreferences(preferences);
+ }
+ }, [preferences]);
+
+ const showSavedConfirmation = useCallback(() => {
+ if (savedTimerRef.current) {
+ clearTimeout(savedTimerRef.current);
+ }
+
+ setShowSaved(true);
+ savedTimerRef.current = window.setTimeout(() => {
+ setShowSaved(false);
+ savedTimerRef.current = null;
+ }, 2000);
+ }, []);
+
+ const clearPendingDebounce = useCallback(() => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ clearPendingDebounce();
+
+ if (savedTimerRef.current) {
+ clearTimeout(savedTimerRef.current);
+ }
+ };
+ }, [clearPendingDebounce]);
+
+ const handleToggleChange = useCallback((key: keyof NotificationPreferences, value: boolean) => {
+ if (!localPreferences) return;
+
+ const newPreferences = { ...localPreferences, [key]: value };
+ setLocalPreferences(newPreferences);
+ clearPendingDebounce();
+
+ debounceTimerRef.current = window.setTimeout(() => {
+ updatePreferences.mutate(newPreferences, {
+ onSuccess: () => {
+ showSavedConfirmation();
+ },
+ });
+ debounceTimerRef.current = null;
+ }, 500);
+ }, [clearPendingDebounce, localPreferences, updatePreferences, showSavedConfirmation]);
+
+ const handleReset = useCallback(() => {
+ setShowResetModal(true);
+ }, []);
+
+ const confirmReset = useCallback(() => {
+ clearPendingDebounce();
+ const resetPreferences = { ...DEFAULT_NOTIFICATION_PREFERENCES };
+ setLocalPreferences(resetPreferences);
+ updatePreferences.mutate(resetPreferences, {
+ onSuccess: () => {
+ setShowResetModal(false);
+ showSavedConfirmation();
+ },
+ });
+ }, [clearPendingDebounce, updatePreferences, showSavedConfirmation]);
+
+ if (isLoading || !localPreferences) {
+ return (
+
+
+
+
Notification Preferences
+
Loading...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Notification Preferences
+
Choose which notifications you'd like to receive
+
+
+
+ {(Object.keys(localPreferences) as Array).map((key) => (
+ handleToggleChange(key, checked)}
+ label={NOTIFICATION_LABELS[key]}
+ disabled={updatePreferences.isPending}
+ />
+ ))}
+
+
+
+
+ Reset to defaults
+
+
+ {showSaved && (
+
+ Saved
+
+ )}
+
+
+
setShowResetModal(false)}
+ onConfirm={confirmReset}
+ title="Reset Preferences"
+ message="Are you sure you want to reset all notification preferences to their default settings? This action cannot be undone."
+ confirmLabel="Reset"
+ isLoading={updatePreferences.isPending}
+ />
+
+
+ );
+}
diff --git a/src/pages/__tests__/NotificationPreferencesPage.test.tsx b/src/pages/__tests__/NotificationPreferencesPage.test.tsx
new file mode 100644
index 0000000..6f29e05
--- /dev/null
+++ b/src/pages/__tests__/NotificationPreferencesPage.test.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import NotificationPreferencesPage from "../NotificationPreferencesPage";
+import type { NotificationPreferences } from "../../types/notifications";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const mockUseNotificationPreferences = vi.fn();
+const mockUseMutateUpdatePreferences = vi.fn();
+
+vi.mock("../../hooks/useNotificationPreferences", () => ({
+ useNotificationPreferences: () => mockUseNotificationPreferences(),
+}));
+
+vi.mock("../../hooks/useMutateUpdatePreferences", () => ({
+ useMutateUpdatePreferences: () => mockUseMutateUpdatePreferences(),
+}));
+
+const enabledPreferences: NotificationPreferences = {
+ APPROVAL_REQUESTED: true,
+ ESCROW_FUNDED: true,
+ DISPUTE_RAISED: true,
+ SETTLEMENT_COMPLETE: true,
+ DOCUMENT_EXPIRING: true,
+ CUSTODY_EXPIRING: true,
+};
+
+function renderPage() {
+ return render( , { wrapper });
+}
+
+describe("NotificationPreferencesPage", () => {
+ beforeEach(() => {
+ queryClient.clear();
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ mockUseNotificationPreferences.mockReturnValue({
+ data: enabledPreferences,
+ isLoading: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ it("debounces toggle saves and only patches once", () => {
+ const mockMutate = vi.fn();
+ mockUseMutateUpdatePreferences.mockReturnValue({
+ mutate: mockMutate,
+ isPending: false,
+ });
+
+ renderPage();
+
+ const approvalToggle = screen.getByText("Approval Requested");
+ fireEvent.click(approvalToggle);
+ fireEvent.click(approvalToggle);
+
+ act(() => {
+ vi.advanceTimersByTime(499);
+ });
+ expect(mockMutate).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledWith(
+ enabledPreferences,
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ );
+ });
+
+ it("requires reset confirmation before patching all-enabled preferences", () => {
+ const mockMutate = vi.fn();
+ mockUseNotificationPreferences.mockReturnValue({
+ data: {
+ ...enabledPreferences,
+ APPROVAL_REQUESTED: false,
+ ESCROW_FUNDED: false,
+ },
+ isLoading: false,
+ });
+ mockUseMutateUpdatePreferences.mockReturnValue({
+ mutate: mockMutate,
+ isPending: false,
+ });
+
+ renderPage();
+
+ fireEvent.click(screen.getByText("Reset to defaults"));
+
+ expect(mockMutate).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Reset"));
+
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledWith(
+ enabledPreferences,
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ );
+ });
+
+ it("shows inline saved confirmation for two seconds after a successful save", () => {
+ const mockMutate = vi.fn((_preferences, options) => {
+ options?.onSuccess?.();
+ });
+ mockUseMutateUpdatePreferences.mockReturnValue({
+ mutate: mockMutate,
+ isPending: false,
+ });
+
+ renderPage();
+
+ fireEvent.click(screen.getByText("Approval Requested"));
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(screen.getByText("Saved")).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/types/notifications.ts b/src/types/notifications.ts
index e2282d6..047fa14 100644
--- a/src/types/notifications.ts
+++ b/src/types/notifications.ts
@@ -1,5 +1,3 @@
-import React from "react";
-
export type NotificationType =
| "APPROVAL_REQUESTED"
| "ESCROW_FUNDED"
@@ -11,6 +9,14 @@ export type NotificationType =
| "adoption"
| "reminder";
+export type NotificationPreferenceType =
+ | "APPROVAL_REQUESTED"
+ | "ESCROW_FUNDED"
+ | "DISPUTE_RAISED"
+ | "SETTLEMENT_COMPLETE"
+ | "DOCUMENT_EXPIRING"
+ | "CUSTODY_EXPIRING";
+
export type NotificationFilter = "all" | "unread" | "read";
export interface Notification {
@@ -27,6 +33,24 @@ export interface Notification {
};
}
+export interface NotificationPreferences {
+ APPROVAL_REQUESTED: boolean;
+ ESCROW_FUNDED: boolean;
+ DISPUTE_RAISED: boolean;
+ SETTLEMENT_COMPLETE: boolean;
+ DOCUMENT_EXPIRING: boolean;
+ CUSTODY_EXPIRING: boolean;
+}
+
+export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
+ APPROVAL_REQUESTED: true,
+ ESCROW_FUNDED: true,
+ DISPUTE_RAISED: true,
+ SETTLEMENT_COMPLETE: true,
+ DOCUMENT_EXPIRING: true,
+ CUSTODY_EXPIRING: true,
+};
+
export interface NotificationsPage {
data: Notification[];
nextCursor: string | null;
diff --git a/vite.config.ts b/vite.config.ts
index 3efc091..0056e70 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,16 +4,17 @@ import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
- // server: {
- // host: "::",
- // port: 5173,
- // open: true,
- // },
- plugins: [react(), tailwindcss()],
server: {
+ host: "localhost",
port: 4321,
strictPort: true,
+ hmr: {
+ host: "localhost",
+ port: 4321,
+ protocol: "ws",
+ },
},
+ plugins: [react(), tailwindcss()],
test: {
environment: "jsdom",
globals: true,