Skip to content
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_MSW=true
40 changes: 28 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<Routes>
{/* Auth Routes - No Navbar/Footer */}
Expand All @@ -32,6 +32,7 @@ function App() {
<Route path="/reset" element={<ResetPasswordPage />} />
<Route path="/forgot-password" element={<ForgetPasswordPage />} />

{/* Main App Routes - With Navbar/Footer */}
<Route element={<MainLayout />}>
<Route path="/home" element={<HomePage />} />
<Route path="/profile" element={<ProfilePage />} />
Expand All @@ -42,22 +43,37 @@ function App() {
<Route path="/list-for-adoption" element={<EditAdoptionListing />} />
<Route path="/my-listings/:id" element={<ListingDetailsPage />} />
<Route path="/notifications" element={<NotificationPage />} />
<Route path="/adoption/:adoptionId/settlement" element={<SettlementSummaryPage />} />
<Route path="/adoption/:adoptionId/timeline" element={<AdoptionTimelinePage />} />

<Route path="/custody/:custodyId/timeline" element={<CustodyTimelinePage />} />

{/* Admin Routes */}
<Route path="/admin/disputes" element={<AdminDisputeListPage />} />
<Route
path="/notification-preferences"
element={<NotificationPreferencesPage />}
/>
<Route
path="/adoption/:adoptionId/settlement"
element={<SettlementSummaryPage />}
/>
<Route
path="/adoption/:adoptionId/timeline"
element={<AdoptionTimelinePage />}
/>
<Route
path="/admin/approvals"
element={<AdminApprovalQueuePage />}
/>
<Route
path="/custody/:custodyId/timeline"
element={<CustodyTimelinePage />}
/>

{/* Preview Routes */}
<Route path="/preview-modal" element={<ModalPreview />} />
<Route path="/adoption-completion-demo" element={<AdoptionCompletionDemo />} />
<Route
path="/adoption-completion-demo"
element={<AdoptionCompletionDemo />}
/>
<Route path="/status-polling-demo" element={<StatusPollingDemo />} />
</Route>
</Routes>
);

}

export default App;
export default App;
6 changes: 3 additions & 3 deletions src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex flex-col bg-white">

Expand All @@ -13,7 +13,7 @@ export function MainLayout() {
<Navbar />

<main className="flex-1">
<Outlet />
{children}
</main>

<Footer />
Expand Down
75 changes: 75 additions & 0 deletions src/components/modals/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-[400px] rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="confirmation-title"
>
<div className="space-y-6 p-8">
<div>
<h2
id="confirmation-title"
className="mb-2 text-[24px] font-bold text-[#0D162B]"
>
{title}
</h2>
<p className="text-[14px] leading-relaxed text-gray-500">
{message}
</p>
</div>

<div className="flex gap-3 pt-2">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 rounded-xl border-2 border-gray-800 py-3 text-[14px] font-semibold text-gray-800 transition-colors hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`flex-1 rounded-xl py-3 text-[14px] font-semibold text-white transition-colors ${
isLoading
? "cursor-not-allowed bg-gray-400"
: "bg-[#E84D2A] hover:bg-[#d4431f]"
}`}
type="button"
>
{isLoading ? "Loading..." : confirmLabel}
</button>
</div>
</div>
</div>
</div>
);
}
5 changes: 4 additions & 1 deletion src/components/modals/index.ts
Original file line number Diff line number Diff line change
@@ -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";
26 changes: 26 additions & 0 deletions src/components/ui/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl border border-gray-200 cursor-pointer select-none transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'bg-gray-50 hover:bg-gray-100'
}`}
onClick={() => !disabled && onChange(!checked)}
>
<div className={`w-10 h-6 flex items-center rounded-full p-1 transition-colors ${
checked ? 'bg-[#E84D2A]' : 'bg-gray-300'
}`}>
<div className={`bg-white w-4 h-4 rounded-full shadow-sm transform transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0'
}`} />
</div>
<span className="text-sm font-bold text-[#0D162B]">{label}</span>
</div>
);
}
98 changes: 98 additions & 0 deletions src/hooks/__tests__/useMutateUpdatePreferences.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

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);
});
});
});
41 changes: 41 additions & 0 deletions src/hooks/useMutateUpdatePreferences.ts
Original file line number Diff line number Diff line change
@@ -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>([
"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"] });
},
});
};
12 changes: 12 additions & 0 deletions src/hooks/useNotificationPreferences.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationPreferences> => {
return getApiClient().get("/notifications/preferences");
},
});
};
5 changes: 4 additions & 1 deletion src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading