From c65230419edd27d78844a1c224f331441df04fee Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Sun, 18 Oct 2026 19:51:16 -0700 Subject: [PATCH 1/2] feat(admin): add useAdminRefunds hook, refund views, timeline panel and KYC onboarding flow - hooks/useAdminRefunds: infinite-query refund list with status, date range and search filters, cursor pagination, bulkMarkReviewed and CSV export - components/admin: refund List, Grid and virtualized Table views and a reusable RefundTimelineDetail panel with click-to-copy identifiers and print styles; adds /admin/refunds page - services: refundService, CSV export in exportDataService (with formula injection protection), kycService.submitForApproval - components/onboarding: four-step KycOnboardingFlow with step indicator, react-hook-form and zod validation; used by the driver KYC page - tests for views, hook, services, timeline panel and onboarding flow --- app/(admin)/admin/refunds/page.tsx | 45 ++++ app/(dashboard)/driver/kyc/page.tsx | 4 +- components/admin/RefundTimelineDetail.tsx | 166 +++++++++++++ .../admin/__tests__/AdminRefundViews.test.tsx | 230 ++++++++++++++++++ .../__tests__/RefundTimelineDetail.test.tsx | 96 ++++++++ components/admin/refunds/RefundGridView.tsx | 95 ++++++++ components/admin/refunds/RefundListView.tsx | 53 ++++ components/admin/refunds/RefundTableView.tsx | 99 ++++++++ components/admin/refunds/shared.tsx | 155 ++++++++++++ components/onboarding/KycOnboardingFlow.tsx | 162 ++++++++++++ .../__tests__/KycOnboardingFlow.test.tsx | 137 +++++++++++ hooks/__tests__/useAdminRefunds.test.tsx | 125 ++++++++++ hooks/useAdminRefunds.ts | 109 +++++++++ hooks/useKycOnboarding.ts | 151 ++++++++++++ hooks/useRefundTimeline.ts | 78 ++++++ hooks/useRefundViewState.ts | 69 ++++++ services/__tests__/adminRefundService.test.ts | 100 ++++++++ services/adminRefundService.ts | 108 ++++++++ services/exportDataService.ts | 35 +++ services/kycService.ts | 12 + 20 files changed, 2027 insertions(+), 2 deletions(-) create mode 100644 app/(admin)/admin/refunds/page.tsx create mode 100644 components/admin/RefundTimelineDetail.tsx create mode 100644 components/admin/__tests__/AdminRefundViews.test.tsx create mode 100644 components/admin/__tests__/RefundTimelineDetail.test.tsx create mode 100644 components/admin/refunds/RefundGridView.tsx create mode 100644 components/admin/refunds/RefundListView.tsx create mode 100644 components/admin/refunds/RefundTableView.tsx create mode 100644 components/admin/refunds/shared.tsx create mode 100644 components/onboarding/KycOnboardingFlow.tsx create mode 100644 components/onboarding/__tests__/KycOnboardingFlow.test.tsx create mode 100644 hooks/__tests__/useAdminRefunds.test.tsx create mode 100644 hooks/useAdminRefunds.ts create mode 100644 hooks/useKycOnboarding.ts create mode 100644 hooks/useRefundTimeline.ts create mode 100644 hooks/useRefundViewState.ts create mode 100644 services/__tests__/adminRefundService.test.ts create mode 100644 services/adminRefundService.ts diff --git a/app/(admin)/admin/refunds/page.tsx b/app/(admin)/admin/refunds/page.tsx new file mode 100644 index 00000000..eca5b29d --- /dev/null +++ b/app/(admin)/admin/refunds/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { RefundGridView } from '@/components/admin/refunds/RefundGridView'; +import { RefundListView } from '@/components/admin/refunds/RefundListView'; +import { RefundTableView } from '@/components/admin/refunds/RefundTableView'; +import { RefundTimelineDetail, type RefundAdminView } from '@/components/admin/RefundTimelineDetail'; + +const VIEWS: RefundAdminView[] = ['list', 'grid', 'table']; + +export default function AdminRefundsPage() { + const [view, setView] = useState('list'); + const [selectedRefundId, setSelectedRefundId] = useState(null); + + return ( +
+
+

Refund Processing

+
+ {VIEWS.map((v) => ( + + ))} +
+
+
+
+ {view === 'list' && } + {view === 'grid' && } + {view === 'table' && } +
+ +
+
+ ); +} diff --git a/app/(dashboard)/driver/kyc/page.tsx b/app/(dashboard)/driver/kyc/page.tsx index fccbab71..94aa3710 100644 --- a/app/(dashboard)/driver/kyc/page.tsx +++ b/app/(dashboard)/driver/kyc/page.tsx @@ -1,9 +1,9 @@ -import KycUpload from '@/features/auth/components/KycUpload'; +import { KycOnboardingFlow } from '@/components/onboarding/KycOnboardingFlow'; export default function KycUploadPage() { return (
- +
); } diff --git a/components/admin/RefundTimelineDetail.tsx b/components/admin/RefundTimelineDetail.tsx new file mode 100644 index 00000000..705f500d --- /dev/null +++ b/components/admin/RefundTimelineDetail.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { Check, Copy, Printer } from 'lucide-react'; +import { useRefundTimeline } from '@/hooks/useRefundTimeline'; + +export type RefundAdminView = 'list' | 'grid' | 'table'; + +interface RefundTimelineDetailProps { + /** Refund/shipment whose timeline is shown */ + refundId: string | null; + /** Admin view hosting the panel (list, grid or table) */ + view?: RefundAdminView; +} + +interface CopyIdProps { + label: string; + value: string; + copied: boolean; + onCopy: (value: string) => void; +} + +function CopyId({ label, value, copied, onCopy }: CopyIdProps) { + return ( + + ); +} + +/** + * RefundTimelineDetail — chronological refund timeline for a selected shipment. + * Reusable across the List, Grid and Table admin views via the `view` prop. + * + * Layered Architecture: + * RefundTimelineDetail -> useRefundTimeline -> adminRefundService + */ +export function RefundTimelineDetail({ refundId, view = 'list' }: RefundTimelineDetailProps) { + const { events, isLoading, error, copy, copiedValue } = useRefundTimeline(refundId); + const onCopy = (value: string) => void copy(value); + + return ( +
+
+
+

Refund Timeline

+

+ Chronological record of refund actions ({events.length}) +

+
+ +
+ + {error && ( +

+ {error} +

+ )} + + {isLoading && events.length === 0 && ( +

Loading timeline...

+ )} + + {!isLoading && !error && events.length === 0 && ( +

+ {refundId ? 'No refund events yet' : 'Select a refund to view its timeline'} +

+ )} + + {events.length > 0 && ( +
    + {events.map((event, index) => { + const isLast = index === events.length - 1; + const date = new Date(event.timestamp); + return ( +
  1. +
    + {!isLast && ( +
    + )} +
    +
    +
    +

    + {event.description} +

    + + {event.action.replace(/-/g, ' ').toUpperCase()} + +
    + +
    +
    +

    + Actor:{' '} + + {event.actorRole && ( + ({event.actorRole}) + )} +

    + {event.transactionHash && ( +

    + Tx hash:{' '} + +

    + )} +

    + Event ID:{' '} + +

    +
    +
    +
  2. + ); + })} +
+ )} +
+ ); +} diff --git a/components/admin/__tests__/AdminRefundViews.test.tsx b/components/admin/__tests__/AdminRefundViews.test.tsx new file mode 100644 index 00000000..5042270e --- /dev/null +++ b/components/admin/__tests__/AdminRefundViews.test.tsx @@ -0,0 +1,230 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RefundListView } from '@/components/admin/refunds/RefundListView'; +import { RefundGridView } from '@/components/admin/refunds/RefundGridView'; +import { RefundTableView } from '@/components/admin/refunds/RefundTableView'; +import { useAdminRefunds, type UseAdminRefundsResult } from '@/hooks/useAdminRefunds'; +import type { RefundRecord } from '@/services/adminRefundService'; + +jest.mock('@/hooks/useAdminRefunds'); + +const mockedHook = useAdminRefunds as jest.MockedFunction; + +const makeRefund = (n: number, overrides: Partial = {}): RefundRecord => ({ + id: `RF-${n}`, + shipmentId: `SH-${n}`, + customerId: `CU-${n}`, + amount: 10 * n, + currency: 'XLM', + status: 'pending', + reason: `Reason ${n}`, + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-02T10:00:00.000Z', + ...overrides, +}); + +const baseResult = (refunds: RefundRecord[], overrides: Partial = {}) => + ({ + refunds, + pagination: { nextCursor: null, total: refunds.length, limit: 20 }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + error: null, + fetchNextPage: jest.fn(), + refetch: jest.fn(), + bulkMarkReviewed: jest.fn().mockResolvedValue({ successCount: 1, errorCount: 0 }), + isBulkLoading: false, + exportSelectedToCsv: jest.fn().mockReturnValue(1), + ...overrides, + }) as UseAdminRefundsResult; + +const refunds = [ + makeRefund(1), + makeRefund(2, { status: 'approved' }), + makeRefund(3, { status: 'rejected' }), +]; + +beforeAll(() => { + // jsdom has no layout; give the virtualized scroll container a size. + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 800 }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockedHook.mockReturnValue(baseResult(refunds)); +}); + +const views = [ + ['List', RefundListView], + ['Grid', RefundGridView], + ['Table', RefundTableView], +] as const; + +describe('List View', () => { + it('renders refund cards with correct metadata', () => { + render(); + expect(screen.getByText('RF-1')).toBeInTheDocument(); + expect(screen.getByText('10.00 XLM')).toBeInTheDocument(); + expect(screen.getByText('Shipment SH-1')).toBeInTheDocument(); + expect(screen.getByText('Reason 1')).toBeInTheDocument(); + const card = screen.getByText('RF-2').closest('li') as HTMLElement; + expect(within(card).getByText('approved')).toBeInTheDocument(); + }); + + it('calls onSelectRefund when a card is clicked', async () => { + const onSelect = jest.fn(); + render(); + await userEvent.click(screen.getByText('Reason 2')); + expect(onSelect).toHaveBeenCalledWith('RF-2'); + }); + + it('shows a load more button when another page exists and fetches it', async () => { + const result = baseResult(refunds, { hasNextPage: true }); + mockedHook.mockReturnValue(result); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Load more' })); + expect(result.fetchNextPage).toHaveBeenCalled(); + }); +}); + +describe('Grid View', () => { + it('renders a card per refund', () => { + render(); + expect(screen.getByRole('button', { name: 'RF-1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'RF-3' })).toBeInTheDocument(); + }); + + it('hides and restores a column via the visibility toggle', async () => { + render(); + expect(screen.getAllByText('Reason')).toHaveLength(4); // toggle label + 3 cards + await userEvent.click(screen.getByRole('checkbox', { name: 'Reason' })); + expect(screen.getAllByText('Reason')).toHaveLength(1); + expect(screen.queryByText('Reason 1')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Reason' })); + expect(screen.getByText('Reason 1')).toBeInTheDocument(); + }); + + it('keeps other columns visible when one is hidden', async () => { + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Customer' })); + expect(screen.queryByText('CU-1')).not.toBeInTheDocument(); + expect(screen.getByText('SH-1')).toBeInTheDocument(); + }); +}); + +describe('Table View', () => { + it('renders virtualized rows with cell data', () => { + render(); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(1); // header + data rows + expect(screen.getByText('SH-1')).toBeInTheDocument(); + }); + + it('renders only a window of rows for large datasets', () => { + const many = Array.from({ length: 500 }, (_, i) => makeRefund(i + 1)); + mockedHook.mockReturnValue(baseResult(many)); + render(); + const dataRows = screen.getAllByRole('row').filter((r) => r.hasAttribute('data-index')); + expect(dataRows.length).toBeGreaterThan(0); + expect(dataRows.length).toBeLessThan(100); + }); + + it('selects all rows from the header checkbox', async () => { + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select all refunds' })); + expect(screen.getByText('3 selected')).toBeInTheDocument(); + }); + + it('calls onSelectRefund when a row is clicked but not when its checkbox is', async () => { + const onSelect = jest.fn(); + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select refund RF-1' })); + expect(onSelect).not.toHaveBeenCalled(); + await userEvent.click(screen.getByText('SH-2')); + expect(onSelect).toHaveBeenCalledWith('RF-2'); + }); +}); + +describe.each(views)('%s View shared behavior', (_name, View) => { + it('passes the chosen status filter to the data hook', async () => { + render(); + expect(mockedHook).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'all' })); + await userEvent.selectOptions(screen.getByLabelText('Filter by status'), 'approved'); + expect(mockedHook).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'approved' })); + }); + + it('passes the search term to the data hook', async () => { + render(); + await userEvent.type(screen.getByLabelText('Search refunds'), 'abc'); + expect(mockedHook).toHaveBeenLastCalledWith(expect.objectContaining({ search: 'abc' })); + }); + + it('shows the empty state when there are no refunds', () => { + mockedHook.mockReturnValue(baseResult([])); + render(); + expect(screen.getByText('No refunds found')).toBeInTheDocument(); + }); + + it('shows the loading state without the empty state', () => { + mockedHook.mockReturnValue(baseResult([], { isLoading: true })); + render(); + expect(screen.getByText('Loading refunds...')).toBeInTheDocument(); + expect(screen.queryByText('No refunds found')).not.toBeInTheDocument(); + }); + + it('shows an error message when loading fails', () => { + mockedHook.mockReturnValue(baseResult([], { error: 'Network down' })); + render(); + expect(screen.getByRole('alert')).toHaveTextContent('Network down'); + }); + + it('disables bulk actions until a refund is selected', () => { + render(); + expect(screen.getByRole('button', { name: 'Mark reviewed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); + }); + + it('selects a refund and runs bulk mark reviewed with its id', async () => { + const result = baseResult(refunds); + mockedHook.mockReturnValue(result); + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select refund RF-2' })); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Mark reviewed' })); + expect(result.bulkMarkReviewed).toHaveBeenCalledWith(['RF-2']); + expect(await screen.findByText('0 selected')).toBeInTheDocument(); + }); + + it('exports only the selected refunds to CSV', async () => { + const result = baseResult(refunds); + mockedHook.mockReturnValue(result); + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select refund RF-1' })); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select refund RF-3' })); + await userEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + expect(result.exportSelectedToCsv).toHaveBeenCalledWith(['RF-1', 'RF-3']); + }); + + it('clears the selection when the status filter changes', async () => { + render(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Select refund RF-1' })); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + await userEvent.selectOptions(screen.getByLabelText('Filter by status'), 'pending'); + expect(screen.getByText('0 selected')).toBeInTheDocument(); + }); + + it('renders every status filter option', () => { + render(); + const options = within(screen.getByLabelText('Filter by status')).getAllByRole('option'); + expect(options.map((o) => o.textContent)).toEqual([ + 'all', + 'pending', + 'reviewed', + 'approved', + 'rejected', + 'completed', + ]); + }); +}); diff --git a/components/admin/__tests__/RefundTimelineDetail.test.tsx b/components/admin/__tests__/RefundTimelineDetail.test.tsx new file mode 100644 index 00000000..72c94646 --- /dev/null +++ b/components/admin/__tests__/RefundTimelineDetail.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RefundTimelineDetail } from '@/components/admin/RefundTimelineDetail'; +import { adminRefundService } from '@/services/adminRefundService'; + +jest.mock('@/services/adminRefundService'); +const svc = adminRefundService as jest.Mocked; + +const events = [ + { + eventId: 'E2', + action: 'refund-approved', + description: 'Refund approved', + timestamp: '2026-03-02T10:00:00Z', + actorId: 'ADMIN-1', + actorRole: 'Admin', + transactionHash: 'TXHASH123', + }, + { + eventId: 'E1', + action: 'refund-requested', + description: 'Refund requested', + timestamp: '2026-03-01T10:00:00Z', + actorId: 'CUST-1', + }, +]; + +describe('RefundTimelineDetail', () => { + let writeText: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + }); + + it('prompts to select a refund when none is chosen', () => { + render(); + expect(screen.getByText('Select a refund to view its timeline')).toBeInTheDocument(); + expect(svc.getRefundTimeline).not.toHaveBeenCalled(); + }); + + it('renders events in chronological order with actors', async () => { + svc.getRefundTimeline.mockResolvedValue(events); + render(); + await screen.findByText('Refund requested'); + const headings = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent); + expect(headings).toEqual(['Refund requested', 'Refund approved']); + expect(screen.getByText('CUST-1')).toBeInTheDocument(); + expect(screen.getByText('(Admin)')).toBeInTheDocument(); + }); + + it('shows the transaction hash only for blockchain events', async () => { + svc.getRefundTimeline.mockResolvedValue(events); + render(); + await screen.findByText('TXHASH123'); + expect(screen.getAllByText('Tx hash:')).toHaveLength(1); + }); + + it('copies identifiers to the clipboard on click', async () => { + svc.getRefundTimeline.mockResolvedValue(events); + render(); + await userEvent.click(await screen.findByRole('button', { name: /Copy transaction hash/ })); + expect(writeText).toHaveBeenCalledWith('TXHASH123'); + await userEvent.click(screen.getByRole('button', { name: /Copy actor ID CUST-1/ })); + expect(writeText).toHaveBeenCalledWith('CUST-1'); + await userEvent.click(screen.getByRole('button', { name: /Copy event ID E2/ })); + expect(writeText).toHaveBeenCalledWith('E2'); + }); + + it('shows an error when loading fails', async () => { + svc.getRefundTimeline.mockRejectedValue(new Error('timeline down')); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('timeline down'); + }); + + it('shows an empty state when the refund has no events', async () => { + svc.getRefundTimeline.mockResolvedValue([]); + render(); + expect(await screen.findByText('No refund events yet')).toBeInTheDocument(); + }); + + it('prints the timeline from the print button', async () => { + svc.getRefundTimeline.mockResolvedValue(events); + const print = jest.spyOn(window, 'print').mockImplementation(() => {}); + render(); + await waitFor(() => expect(svc.getRefundTimeline).toHaveBeenCalled()); + await userEvent.click(screen.getByRole('button', { name: 'Print timeline' })); + expect(print).toHaveBeenCalled(); + expect(screen.getByLabelText('Refund timeline')).toHaveAttribute('data-view', 'table'); + print.mockRestore(); + }); +}); diff --git a/components/admin/refunds/RefundGridView.tsx b/components/admin/refunds/RefundGridView.tsx new file mode 100644 index 00000000..583fe2e4 --- /dev/null +++ b/components/admin/refunds/RefundGridView.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { useAdminRefunds } from '@/hooks/useAdminRefunds'; +import { useRefundViewState } from '@/hooks/useRefundViewState'; +import type { RefundRecord } from '@/services/adminRefundService'; +import { + LoadMore, + REFUND_COLUMNS, + RefundFeedback, + RefundToolbar, + StatusBadge, + formatCell, +} from './shared'; + +interface RefundViewProps { + onSelectRefund?: (refundId: string) => void; +} + +/** Grid view: compact cards whose visible columns can be toggled. */ +export function RefundGridView({ onSelectRefund }: RefundViewProps) { + const state = useRefundViewState(); + const data = useAdminRefunds(state.filters); + const [hidden, setHidden] = useState>(new Set()); + + const toggleColumn = (key: keyof RefundRecord) => + setHidden((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const visible = REFUND_COLUMNS.filter((c) => !hidden.has(c.key)); + + return ( +
+ +
+ Visible columns + {REFUND_COLUMNS.filter((c) => c.key !== 'id').map((col) => ( + + ))} +
+ +
+ {data.refunds.map((refund) => ( +
+
+ +
+
+ {visible + .filter((c) => c.key !== 'id') + .map((col) => ( +
+
{col.label}
+
+ {col.key === 'status' ? ( + + ) : ( + formatCell(refund, col.key) + )} +
+
+ ))} +
+
+ ))} +
+ +
+ ); +} diff --git a/components/admin/refunds/RefundListView.tsx b/components/admin/refunds/RefundListView.tsx new file mode 100644 index 00000000..060dd460 --- /dev/null +++ b/components/admin/refunds/RefundListView.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useAdminRefunds } from '@/hooks/useAdminRefunds'; +import { useRefundViewState } from '@/hooks/useRefundViewState'; +import { LoadMore, RefundFeedback, RefundToolbar, StatusBadge, formatAmount, formatCell } from './shared'; + +interface RefundViewProps { + onSelectRefund?: (refundId: string) => void; +} + +/** List view: one card per refund with its key metadata. */ +export function RefundListView({ onSelectRefund }: RefundViewProps) { + const state = useRefundViewState(); + const data = useAdminRefunds(state.filters); + + return ( +
+ + +
    + {data.refunds.map((refund) => ( +
  • + state.toggle(refund.id)} + className="mt-1" + /> + +
  • + ))} +
+ +
+ ); +} diff --git a/components/admin/refunds/RefundTableView.tsx b/components/admin/refunds/RefundTableView.tsx new file mode 100644 index 00000000..ae02bd4d --- /dev/null +++ b/components/admin/refunds/RefundTableView.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAdminRefunds } from '@/hooks/useAdminRefunds'; +import { useRefundViewState } from '@/hooks/useRefundViewState'; +import { + LoadMore, + REFUND_COLUMNS, + RefundFeedback, + RefundToolbar, + StatusBadge, + formatCell, +} from './shared'; + +interface RefundViewProps { + onSelectRefund?: (refundId: string) => void; +} + +const ROW_HEIGHT = 44; + +/** Table view: virtualized rows so only the visible window is rendered. */ +export function RefundTableView({ onSelectRefund }: RefundViewProps) { + const state = useRefundViewState(); + const data = useAdminRefunds(state.filters); + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: data.refunds.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 5, + }); + + const allSelected = + data.refunds.length > 0 && data.refunds.every((r) => state.isSelected(r.id)); + + return ( +
+ + + {data.refunds.length > 0 && ( +
+
+
+ state.toggleAll(data.refunds)} + /> +
+ {REFUND_COLUMNS.map((col) => ( +
+ {col.label} +
+ ))} +
+
+
+ {virtualizer.getVirtualItems().map((item) => { + const refund = data.refunds[item.index]; + return ( +
onSelectRefund?.(refund.id)} + className="absolute left-0 flex w-full cursor-pointer items-center gap-2 border-b border-gray-100 px-3 text-sm hover:bg-gray-50" + style={{ height: item.size, transform: `translateY(${item.start}px)` }} + > +
e.stopPropagation()}> + state.toggle(refund.id)} + /> +
+ {REFUND_COLUMNS.map((col) => ( +
+ {col.key === 'status' ? ( + + ) : ( + formatCell(refund, col.key) + )} +
+ ))} +
+ ); + })} +
+
+
+ )} + +
+ ); +} diff --git a/components/admin/refunds/shared.tsx b/components/admin/refunds/shared.tsx new file mode 100644 index 00000000..243d7c22 --- /dev/null +++ b/components/admin/refunds/shared.tsx @@ -0,0 +1,155 @@ +'use client'; + +import type { RefundRecord, RefundStatus } from '@/services/adminRefundService'; +import type { UseAdminRefundsResult } from '@/hooks/useAdminRefunds'; +import type { UseRefundViewStateResult } from '@/hooks/useRefundViewState'; + +export const STATUS_OPTIONS: Array = [ + 'all', + 'pending', + 'reviewed', + 'approved', + 'rejected', + 'completed', +]; + +export interface RefundColumn { + key: keyof RefundRecord; + label: string; +} + +export const REFUND_COLUMNS: RefundColumn[] = [ + { key: 'id', label: 'Refund ID' }, + { key: 'shipmentId', label: 'Shipment' }, + { key: 'customerId', label: 'Customer' }, + { key: 'amount', label: 'Amount' }, + { key: 'status', label: 'Status' }, + { key: 'reason', label: 'Reason' }, + { key: 'createdAt', label: 'Created' }, +]; + +export function formatAmount(refund: RefundRecord): string { + return `${refund.amount.toFixed(2)} ${refund.currency}`; +} + +export function formatCell(refund: RefundRecord, key: keyof RefundRecord): string { + if (key === 'amount') return formatAmount(refund); + if (key === 'createdAt' || key === 'updatedAt') { + return new Date(refund[key]).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + return String(refund[key]); +} + +export function StatusBadge({ status }: { status: RefundStatus }) { + const colors: Record = { + pending: 'bg-yellow-100 text-yellow-800', + reviewed: 'bg-blue-100 text-blue-800', + approved: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', + completed: 'bg-emerald-100 text-emerald-800', + }; + return ( + + {status} + + ); +} + +interface RefundToolbarProps { + state: UseRefundViewStateResult; + data: UseAdminRefundsResult; +} + +/** Status/search filters plus bulk actions shared by every refund view. */ +export function RefundToolbar({ state, data }: RefundToolbarProps) { + const count = state.selectedIds.length; + + const handleBulkReview = async () => { + const result = await data.bulkMarkReviewed(state.selectedIds); + state.clearSelection(); + return result; + }; + + return ( +
+ + state.setSearch(e.target.value)} + className="rounded-lg border border-gray-300 px-3 py-1 text-sm" + /> +
+ + {count} selected + + + +
+
+ ); +} + +export function RefundFeedback({ data }: { data: UseAdminRefundsResult }) { + return ( + <> + {data.error && ( +

+ {data.error} +

+ )} + {data.isLoading &&

Loading refunds...

} + {!data.isLoading && !data.error && data.refunds.length === 0 && ( +

No refunds found

+ )} + + ); +} + +export function LoadMore({ data }: { data: UseAdminRefundsResult }) { + if (!data.hasNextPage) return null; + return ( + + ); +} diff --git a/components/onboarding/KycOnboardingFlow.tsx b/components/onboarding/KycOnboardingFlow.tsx new file mode 100644 index 00000000..63b3918e --- /dev/null +++ b/components/onboarding/KycOnboardingFlow.tsx @@ -0,0 +1,162 @@ +'use client'; + +import type { ChangeEvent } from 'react'; +import { Check } from 'lucide-react'; +import { + KYC_FIELD_LABELS, + useKycOnboarding, + type KycFieldName, +} from '@/hooks/useKycOnboarding'; + +/** + * KycOnboardingFlow — multi-step driver KYC onboarding with a step indicator. + * + * Layered Architecture: + * KycOnboardingFlow -> useKycOnboarding -> kycService + */ +export function KycOnboardingFlow() { + const { + form, + step, + steps, + isLastStep, + isSubmitting, + isSubmitted, + submitError, + setFile, + next, + back, + goTo, + submit, + } = useKycOnboarding(); + + const { errors } = form.formState; + const values = form.watch(); + + if (isSubmitted) { + return ( +
+

Documents submitted

+

Your documents are awaiting admin approval.

+
+ ); + } + + const renderFile = (name: KycFieldName) => { + const file = values[name] as File | undefined; + const error = errors[name]?.message; + return ( +
+ + ) => setFile(name, e.target.files?.[0])} + className="block w-full text-sm" + /> + {file instanceof File &&

{file.name}

} + {error && ( + + )} +
+ ); + }; + + return ( +
+

+ Step {step + 1} of {steps.length} +

+
    + {steps.map((s, i) => { + const done = i < step; + const active = i === step; + return ( +
  1. + + + {s.title} + +
  2. + ); + })} +
+ +
{ + e.preventDefault(); + if (isLastStep) void submit(); + else void next(); + }} + noValidate + className="rounded-xl border border-gray-200 bg-white p-6" + > +

{steps[step].title}

+ + {steps[step].fields.map(renderFile)} + + {isLastStep && ( +
+ {(Object.keys(KYC_FIELD_LABELS) as KycFieldName[]).map((name) => ( +
+
{KYC_FIELD_LABELS[name]}
+
+ {(values[name] as File | undefined)?.name ?? 'Missing'} +
+
+ ))} +
+ )} + + {submitError && ( +

+ {submitError} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/components/onboarding/__tests__/KycOnboardingFlow.test.tsx b/components/onboarding/__tests__/KycOnboardingFlow.test.tsx new file mode 100644 index 00000000..100fad59 --- /dev/null +++ b/components/onboarding/__tests__/KycOnboardingFlow.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { KycOnboardingFlow } from '@/components/onboarding/KycOnboardingFlow'; +import { kycService } from '@/services/kycService'; + +jest.mock('@/services/kycService'); +const svc = kycService as jest.Mocked; + +const pdf = (name: string) => new File(['x'], name, { type: 'application/pdf' }); + +async function upload(label: string | RegExp, file: File) { + await userEvent.upload(screen.getByLabelText(label), file); +} + +async function completeStep1() { + await upload(/front/, pdf('front.pdf')); + await upload(/back/, pdf('back.pdf')); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); +} + +describe('KycOnboardingFlow', () => { + beforeEach(() => jest.clearAllMocks()); + + it('shows the step indicator starting at step 1 of 4', () => { + render(); + expect(screen.getByText('Step 1 of 4')).toBeInTheDocument(); + const active = screen.getByRole('button', { name: /^Step 1:/ }); + expect(active).toHaveAttribute('aria-current', 'step'); + expect(screen.getByRole('button', { name: /^Step 2:/ })).not.toHaveAttribute('aria-current'); + }); + + it('blocks progression when the step is incomplete', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Step 1 of 4')).toBeInTheDocument(); + expect((await screen.findAllByRole('alert')).length).toBeGreaterThan(0); + }); + + it('rejects files with an unsupported type', async () => { + render(); + await userEvent.upload( + screen.getByLabelText(/front/), + new File(['x'], 'front.txt', { type: 'text/plain' }), + { applyAccept: false } + ); + expect(await screen.findByText(/must be PDF, JPG, or PNG/)).toBeInTheDocument(); + }); + + it('rejects files larger than 5MB', async () => { + render(); + const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.pdf', { + type: 'application/pdf', + }); + await upload(/front/, big); + expect(await screen.findByText(/must not exceed 5MB/)).toBeInTheDocument(); + }); + + it('advances to step 2 when step 1 is complete', async () => { + render(); + await completeStep1(); + expect(await screen.findByText('Step 2 of 4')).toBeInTheDocument(); + expect(screen.getByLabelText('Vehicle registration')).toBeInTheDocument(); + }); + + it('allows returning to a previous step and keeps its files', async () => { + render(); + await completeStep1(); + await screen.findByText('Step 2 of 4'); + await userEvent.click(screen.getByRole('button', { name: /^Step 1:/ })); + expect(screen.getByText('Step 1 of 4')).toBeInTheDocument(); + expect(screen.getByText('front.pdf')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByText('Step 2 of 4'); + await userEvent.click(screen.getByRole('button', { name: 'Previous' })); + expect(screen.getByText('Step 1 of 4')).toBeInTheDocument(); + }); + + it('does not allow jumping ahead to a later step', async () => { + render(); + expect(screen.getByRole('button', { name: /^Step 3:/ })).toBeDisabled(); + }); + + it('uploads all documents and submits for approval from the review step', async () => { + let n = 0; + svc.uploadDocument.mockImplementation(async () => ({ + success: true, + message: 'ok', + data: { + id: `doc-${++n}`, + filename: 'f', + fileType: 'application/pdf', + fileSize: 1, + uploadedAt: '2026-03-01T00:00:00Z', + status: 'pending', + }, + })); + svc.submitForApproval.mockResolvedValue({ success: true, message: 'ok' }); + + render(); + await completeStep1(); + await upload('Vehicle registration', pdf('reg.pdf')); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + await upload('Profile photo', new File(['x'], 'me.png', { type: 'image/png' })); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(await screen.findByText('Step 4 of 4')).toBeInTheDocument(); + expect(screen.getByText('reg.pdf')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Submit for approval' })); + + await waitFor(() => expect(screen.getByText('Documents submitted')).toBeInTheDocument()); + expect(svc.uploadDocument).toHaveBeenCalledTimes(4); + const types = svc.uploadDocument.mock.calls.map((c) => c[0].get('documentType')); + expect(types).toEqual([ + 'drivers-license-front', + 'drivers-license-back', + 'vehicle-registration', + 'profile-photo', + ]); + expect(svc.submitForApproval).toHaveBeenCalledWith(['doc-1', 'doc-2', 'doc-3', 'doc-4']); + }); + + it('shows an error and stays on review when the upload fails', async () => { + svc.uploadDocument.mockRejectedValue({ response: { data: { message: 'Server said no' } } }); + + render(); + await completeStep1(); + await upload('Vehicle registration', pdf('reg.pdf')); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + await upload('Profile photo', new File(['x'], 'me.png', { type: 'image/png' })); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + await userEvent.click(await screen.findByRole('button', { name: 'Submit for approval' })); + + expect(await screen.findByText('Server said no')).toBeInTheDocument(); + expect(svc.submitForApproval).not.toHaveBeenCalled(); + expect(screen.getByText('Step 4 of 4')).toBeInTheDocument(); + }); +}); diff --git a/hooks/__tests__/useAdminRefunds.test.tsx b/hooks/__tests__/useAdminRefunds.test.tsx new file mode 100644 index 00000000..0581b745 --- /dev/null +++ b/hooks/__tests__/useAdminRefunds.test.tsx @@ -0,0 +1,125 @@ +import { ReactNode } from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useAdminRefunds } from '@/hooks/useAdminRefunds'; +import { adminRefundService, type RefundRecord } from '@/services/adminRefundService'; +import { exportDataService } from '@/services/exportDataService'; + +jest.mock('@/services/adminRefundService'); +jest.mock('@/services/exportDataService', () => ({ + exportDataService: { exportToCsv: jest.fn() }, +})); + +const svc = adminRefundService as jest.Mocked; + +const refund = (id: string): RefundRecord => ({ + id, + shipmentId: `S-${id}`, + customerId: 'C-1', + amount: 5, + currency: 'XLM', + status: 'pending', + reason: 'late', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-01T00:00:00Z', +}); + +let client: QueryClient; +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useAdminRefunds', () => { + beforeEach(() => { + jest.clearAllMocks(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + it('returns typed refunds with pagination metadata', async () => { + svc.getRefunds.mockResolvedValue({ + refunds: [refund('1'), refund('2')], + pagination: { nextCursor: 'c2', total: 4, limit: 2 }, + }); + const { result } = renderHook(() => useAdminRefunds(), { wrapper }); + await waitFor(() => expect(result.current.refunds).toHaveLength(2)); + expect(result.current.pagination).toEqual({ nextCursor: 'c2', total: 4, limit: 2 }); + expect(result.current.hasNextPage).toBe(true); + }); + + it('passes filters and the cursor to the service', async () => { + svc.getRefunds + .mockResolvedValueOnce({ + refunds: [refund('1')], + pagination: { nextCursor: 'c2', total: 2, limit: 1 }, + }) + .mockResolvedValueOnce({ + refunds: [refund('2')], + pagination: { nextCursor: null, total: 2, limit: 1 }, + }); + const filters = { status: 'pending' as const, search: 'abc' }; + const { result } = renderHook(() => useAdminRefunds(filters), { wrapper }); + await waitFor(() => expect(result.current.refunds).toHaveLength(1)); + expect(svc.getRefunds).toHaveBeenCalledWith(expect.objectContaining(filters), null); + + await act(async () => { + await result.current.fetchNextPage(); + }); + expect(svc.getRefunds).toHaveBeenLastCalledWith(expect.objectContaining(filters), 'c2'); + await waitFor(() => expect(result.current.refunds.map((r) => r.id)).toEqual(['1', '2'])); + expect(result.current.hasNextPage).toBe(false); + }); + + it('exposes the error message when loading fails', async () => { + svc.getRefunds.mockRejectedValue(new Error('boom')); + const { result } = renderHook(() => useAdminRefunds(), { wrapper }); + await waitFor(() => expect(result.current.error).toBe('boom')); + expect(result.current.refunds).toEqual([]); + }); + + it('returns success and error counts from bulkMarkReviewed and refetches', async () => { + svc.getRefunds.mockResolvedValue({ + refunds: [refund('1')], + pagination: { nextCursor: null, total: 1, limit: 20 }, + }); + svc.bulkMarkReviewed.mockResolvedValue({ successCount: 2, errorCount: 1 }); + const { result } = renderHook(() => useAdminRefunds(), { wrapper }); + await waitFor(() => expect(result.current.refunds).toHaveLength(1)); + + let counts; + await act(async () => { + counts = await result.current.bulkMarkReviewed(['1', '2', '3']); + }); + expect(counts).toEqual({ successCount: 2, errorCount: 1 }); + expect(svc.bulkMarkReviewed).toHaveBeenCalledWith(['1', '2', '3']); + await waitFor(() => expect(svc.getRefunds).toHaveBeenCalledTimes(2)); + }); + + it('exports only selected refunds to CSV', async () => { + svc.getRefunds.mockResolvedValue({ + refunds: [refund('1'), refund('2')], + pagination: { nextCursor: null, total: 2, limit: 20 }, + }); + const { result } = renderHook(() => useAdminRefunds(), { wrapper }); + await waitFor(() => expect(result.current.refunds).toHaveLength(2)); + + let count = 0; + act(() => { + count = result.current.exportSelectedToCsv(['2']); + }); + expect(count).toBe(1); + const rows = (exportDataService.exportToCsv as jest.Mock).mock.calls[0][0]; + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('2'); + }); + + it('does not export when nothing matches the selection', async () => { + svc.getRefunds.mockResolvedValue({ + refunds: [refund('1')], + pagination: { nextCursor: null, total: 1, limit: 20 }, + }); + const { result } = renderHook(() => useAdminRefunds(), { wrapper }); + await waitFor(() => expect(result.current.refunds).toHaveLength(1)); + expect(result.current.exportSelectedToCsv(['nope'])).toBe(0); + expect(exportDataService.exportToCsv).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/useAdminRefunds.ts b/hooks/useAdminRefunds.ts new file mode 100644 index 00000000..dcd40463 --- /dev/null +++ b/hooks/useAdminRefunds.ts @@ -0,0 +1,109 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + adminRefundService, + type BulkReviewResult, + type RefundFilters, + type RefundPagination, + type RefundRecord, +} from '@/services/adminRefundService'; +import { exportDataService } from '@/services/exportDataService'; + +export const REFUND_CSV_COLUMNS = [ + 'id', + 'shipmentId', + 'customerId', + 'amount', + 'currency', + 'status', + 'reason', + 'createdAt', + 'updatedAt', +] as const; + +export interface UseAdminRefundsResult { + /** Refunds from all loaded pages, in server order */ + refunds: RefundRecord[]; + /** Pagination metadata from the most recently loaded page */ + pagination: RefundPagination | null; + isLoading: boolean; + isFetchingNextPage: boolean; + hasNextPage: boolean; + error: string | null; + /** Load the next page using the server-provided cursor (infinite scroll) */ + fetchNextPage: () => Promise; + refetch: () => Promise; + /** Marks refunds as reviewed; resolves with success/error counts */ + bulkMarkReviewed: (ids: string[]) => Promise; + isBulkLoading: boolean; + /** Exports the refunds with the given ids (from loaded data) to CSV; returns rows exported */ + exportSelectedToCsv: (ids: string[]) => number; +} + +/** + * useAdminRefunds — fetches and manages admin refund processing data. + * + * Follows the Component -> Hook -> Service pattern: + * Refund views (components) -> useAdminRefunds (hook) -> adminRefundService (service) + */ +export function useAdminRefunds(filters: RefundFilters = {}): UseAdminRefundsResult { + const queryClient = useQueryClient(); + const { status, dateFrom, dateTo, search } = filters; + const normalized = useMemo( + () => ({ status, dateFrom, dateTo, search }), + [status, dateFrom, dateTo, search] + ); + + const query = useInfiniteQuery({ + queryKey: ['admin-refunds', normalized], + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => adminRefundService.getRefunds(normalized, pageParam), + getNextPageParam: (lastPage) => lastPage.pagination.nextCursor ?? undefined, + }); + + const mutation = useMutation({ + mutationFn: (ids: string[]) => adminRefundService.bulkMarkReviewed(ids), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-refunds'] }), + }); + + const refunds = useMemo( + () => query.data?.pages.flatMap((page) => page.refunds) ?? [], + [query.data] + ); + const pages = query.data?.pages; + const pagination = pages && pages.length > 0 ? pages[pages.length - 1].pagination : null; + + const { mutateAsync } = mutation; + const bulkMarkReviewed = useCallback((ids: string[]) => mutateAsync(ids), [mutateAsync]); + + const exportSelectedToCsv = useCallback( + (ids: string[]) => { + const selected = new Set(ids); + const rows = refunds.filter((r) => selected.has(r.id)); + if (rows.length === 0) return 0; + exportDataService.exportToCsv( + rows as unknown as Record[], + [...REFUND_CSV_COLUMNS], + `refunds-${new Date().toISOString().slice(0, 10)}.csv` + ); + return rows.length; + }, + [refunds] + ); + + return { + refunds, + pagination, + isLoading: query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + error: query.error ? (query.error as Error).message : null, + fetchNextPage: query.fetchNextPage, + refetch: query.refetch, + bulkMarkReviewed, + isBulkLoading: mutation.isPending, + exportSelectedToCsv, + }; +} diff --git a/hooks/useKycOnboarding.ts b/hooks/useKycOnboarding.ts new file mode 100644 index 00000000..be1a7bd6 --- /dev/null +++ b/hooks/useKycOnboarding.ts @@ -0,0 +1,151 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { kycService } from '@/services/kycService'; + +export const KYC_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +export const KYC_ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png']; + +export type KycFieldName = + | 'licenseFront' + | 'licenseBack' + | 'vehicleRegistration' + | 'profilePhoto'; + +const fileField = (label: string) => + z + .custom((v) => typeof File !== 'undefined' && v instanceof File, { + message: `${label} is required`, + }) + .refine((f) => !(f instanceof File) || f.size <= KYC_MAX_FILE_SIZE, { + message: `${label} must not exceed 5MB`, + }) + .refine((f) => !(f instanceof File) || KYC_ALLOWED_TYPES.includes(f.type), { + message: `${label} must be PDF, JPG, or PNG`, + }); + +const schema = z.object({ + licenseFront: fileField("Driver's license (front)"), + licenseBack: fileField("Driver's license (back)"), + vehicleRegistration: fileField('Vehicle registration'), + profilePhoto: fileField('Profile photo'), +}); + +export type KycOnboardingValues = z.infer; + +export const KYC_STEPS: Array<{ title: string; fields: KycFieldName[] }> = [ + { title: "Driver's license", fields: ['licenseFront', 'licenseBack'] }, + { title: 'Vehicle registration', fields: ['vehicleRegistration'] }, + { title: 'Profile photo', fields: ['profilePhoto'] }, + { title: 'Review & submit', fields: [] }, +]; + +const DOCUMENT_TYPES: Record = { + licenseFront: 'drivers-license-front', + licenseBack: 'drivers-license-back', + vehicleRegistration: 'vehicle-registration', + profilePhoto: 'profile-photo', +}; + +export const KYC_FIELD_LABELS: Record = { + licenseFront: "Driver's license (front)", + licenseBack: "Driver's license (back)", + vehicleRegistration: 'Vehicle registration', + profilePhoto: 'Profile photo', +}; + +/** + * useKycOnboarding — state for the multi-step driver KYC onboarding flow. + * + * Follows the Component -> Hook -> Service pattern: + * KycOnboardingFlow -> useKycOnboarding -> kycService + */ +export function useKycOnboarding() { + const [step, setStep] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const form = useForm({ + resolver: zodResolver(schema), + mode: 'onChange', + defaultValues: { + licenseFront: undefined, + licenseBack: undefined, + vehicleRegistration: undefined, + profilePhoto: undefined, + }, + }); + + const setFile = useCallback( + (name: KycFieldName, file: File | undefined) => { + form.setValue(name, file as File, { shouldValidate: true, shouldDirty: true }); + }, + [form] + ); + + /** Advances only when every field of the current step is valid. */ + const next = useCallback(async () => { + const fields = KYC_STEPS[step].fields; + const valid = fields.length === 0 ? true : await form.trigger(fields); + if (valid && step < KYC_STEPS.length - 1) setStep(step + 1); + return valid; + }, [form, step]); + + /** Previous steps are always reachable. */ + const goTo = useCallback( + (target: number) => { + if (target >= 0 && target < step) setStep(target); + }, + [step] + ); + + const back = useCallback(() => goTo(step - 1), [goTo, step]); + + const submit = useCallback(async () => { + setSubmitError(null); + const valid = await form.trigger(); + if (!valid) return false; + setIsSubmitting(true); + try { + const values = form.getValues(); + const ids: string[] = []; + for (const name of Object.keys(DOCUMENT_TYPES) as KycFieldName[]) { + const formData = new FormData(); + formData.append('document', values[name]); + formData.append('documentType', DOCUMENT_TYPES[name]); + const res = await kycService.uploadDocument(formData); + if (!res.success || !res.data) throw new Error(res.message || 'Upload failed'); + ids.push(res.data.id); + } + const res = await kycService.submitForApproval(ids); + if (!res.success) throw new Error(res.message || 'Submission failed'); + setIsSubmitted(true); + return true; + } catch (err) { + const e = err as { response?: { data?: { message?: string } }; message?: string }; + setSubmitError(e.response?.data?.message || e.message || 'Failed to submit documents'); + return false; + } finally { + setIsSubmitting(false); + } + }, [form]); + + return { + form, + step, + steps: KYC_STEPS, + isLastStep: step === KYC_STEPS.length - 1, + isSubmitting, + isSubmitted, + submitError, + setFile, + next, + back, + goTo, + submit, + }; +} diff --git a/hooks/useRefundTimeline.ts b/hooks/useRefundTimeline.ts new file mode 100644 index 00000000..d17ac8df --- /dev/null +++ b/hooks/useRefundTimeline.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { adminRefundService, type RefundTimelineEvent } from '@/services/adminRefundService'; + +export interface UseRefundTimelineResult { + /** Events ordered oldest to newest */ + events: RefundTimelineEvent[]; + isLoading: boolean; + error: string | null; + /** Copies text to the clipboard; returns true on success */ + copy: (value: string) => Promise; + /** The most recently copied value, cleared after a short delay */ + copiedValue: string | null; +} + +/** + * useRefundTimeline — loads the chronological refund timeline for a shipment + * and exposes click-to-copy support for identifiers. + */ +export function useRefundTimeline(refundId: string | null): UseRefundTimelineResult { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [copiedValue, setCopiedValue] = useState(null); + const timer = useRef | null>(null); + + useEffect(() => { + if (!refundId) return; + let cancelled = false; + Promise.resolve().then(() => { + if (cancelled) return; + setIsLoading(true); + setError(null); + }); + adminRefundService + .getRefundTimeline(refundId) + .then((data) => { + if (cancelled) return; + setEvents( + [...data].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + ); + }) + .catch((err) => { + if (!cancelled) { + setEvents([]); + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refundId]); + + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [] + ); + + const copy = useCallback(async (value: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedValue(value); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopiedValue(null), 1500); + return true; + } catch { + return false; + } + }, []); + + return { events: refundId ? events : [], isLoading, error, copy, copiedValue }; +} diff --git a/hooks/useRefundViewState.ts b/hooks/useRefundViewState.ts new file mode 100644 index 00000000..36ac42d4 --- /dev/null +++ b/hooks/useRefundViewState.ts @@ -0,0 +1,69 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { RefundFilters, RefundRecord } from '@/services/adminRefundService'; + +export interface UseRefundViewStateResult { + filters: RefundFilters; + setStatus: (status: RefundFilters['status']) => void; + setSearch: (search: string) => void; + setDateRange: (dateFrom?: string, dateTo?: string) => void; + selectedIds: string[]; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; + toggleAll: (refunds: RefundRecord[]) => void; + clearSelection: () => void; +} + +/** + * useRefundViewState — filter and bulk-selection state shared by the + * List, Grid and Table admin refund views. + */ +export function useRefundViewState(initial: RefundFilters = {}): UseRefundViewStateResult { + const [filters, setFilters] = useState({ status: 'all', ...initial }); + const [selected, setSelected] = useState>(new Set()); + + const setStatus = useCallback((status: RefundFilters['status']) => { + setFilters((f) => ({ ...f, status })); + setSelected(new Set()); + }, []); + const setSearch = useCallback((search: string) => { + setFilters((f) => ({ ...f, search })); + }, []); + const setDateRange = useCallback((dateFrom?: string, dateTo?: string) => { + setFilters((f) => ({ ...f, dateFrom, dateTo })); + }, []); + + const toggle = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleAll = useCallback((refunds: RefundRecord[]) => { + setSelected((prev) => + refunds.length > 0 && refunds.every((r) => prev.has(r.id)) + ? new Set() + : new Set(refunds.map((r) => r.id)) + ); + }, []); + + const clearSelection = useCallback(() => setSelected(new Set()), []); + const isSelected = useCallback((id: string) => selected.has(id), [selected]); + const selectedIds = useMemo(() => Array.from(selected), [selected]); + + return { + filters, + setStatus, + setSearch, + setDateRange, + selectedIds, + isSelected, + toggle, + toggleAll, + clearSelection, + }; +} diff --git a/services/__tests__/adminRefundService.test.ts b/services/__tests__/adminRefundService.test.ts new file mode 100644 index 00000000..47ba8608 --- /dev/null +++ b/services/__tests__/adminRefundService.test.ts @@ -0,0 +1,100 @@ +import api from '@/lib/api'; +import { adminRefundService } from '@/services/adminRefundService'; +import { exportDataService } from '@/services/exportDataService'; + +jest.mock('@/lib/api', () => ({ __esModule: true, default: { get: jest.fn(), post: jest.fn() } })); + +const mockApi = api as unknown as { get: jest.Mock; post: jest.Mock }; + +describe('adminRefundService', () => { + beforeEach(() => jest.clearAllMocks()); + + it('builds query params and omits empty filters', () => { + expect(adminRefundService.buildParams({ status: 'all', search: ' ' })).toEqual({ limit: 20 }); + expect( + adminRefundService.buildParams( + { status: 'pending', dateFrom: '2026-01-01', dateTo: '2026-02-01', search: ' x ' }, + 'cur', + 10 + ) + ).toEqual({ + limit: 10, + status: 'pending', + dateFrom: '2026-01-01', + dateTo: '2026-02-01', + search: 'x', + cursor: 'cur', + }); + }); + + it('fetches refunds from /admin/refunds with filters as query params', async () => { + const payload = { refunds: [], pagination: { nextCursor: null, total: 0, limit: 20 } }; + mockApi.get.mockResolvedValue({ data: { success: true, data: payload } }); + await expect(adminRefundService.getRefunds({ status: 'approved' })).resolves.toEqual(payload); + expect(mockApi.get).toHaveBeenCalledWith('/admin/refunds', { + params: { limit: 20, status: 'approved' }, + }); + }); + + it('throws the server message when the response is unsuccessful', async () => { + mockApi.get.mockResolvedValue({ data: { success: false, message: 'nope' } }); + await expect(adminRefundService.getRefunds({})).rejects.toThrow('nope'); + }); + + it('posts ids for bulk review', async () => { + mockApi.post.mockResolvedValue({ + data: { success: true, data: { successCount: 1, errorCount: 0 } }, + }); + await expect(adminRefundService.bulkMarkReviewed(['a'])).resolves.toEqual({ + successCount: 1, + errorCount: 0, + }); + expect(mockApi.post).toHaveBeenCalledWith('/admin/refunds/bulk-review', { ids: ['a'] }); + }); + + it('throws when bulk review is unsuccessful', async () => { + mockApi.post.mockResolvedValue({ data: { success: false } }); + await expect(adminRefundService.bulkMarkReviewed(['a'])).rejects.toThrow( + 'Failed to mark refunds as reviewed' + ); + }); + + it('fetches a timeline with an encoded id', async () => { + mockApi.get.mockResolvedValue({ data: { success: true, data: { events: [] } } }); + await adminRefundService.getRefundTimeline('a/b'); + expect(mockApi.get).toHaveBeenCalledWith('/admin/refunds/a%2Fb/timeline'); + }); + + it('throws when the timeline request is unsuccessful', async () => { + mockApi.get.mockResolvedValue({ data: { success: false } }); + await expect(adminRefundService.getRefundTimeline('a')).rejects.toThrow( + 'Failed to fetch refund timeline' + ); + }); +}); + +describe('exportDataService CSV export', () => { + it('writes a header and escapes commas, quotes and newlines', () => { + const csv = exportDataService.toCsv( + [{ a: 'x,y', b: 'say "hi"', c: 'l1\nl2' }], + ['a', 'b', 'c'] + ); + expect(csv).toBe('a,b,c\n"x,y","say ""hi""","l1\nl2"'); + }); + + it('neutralises spreadsheet formula injection', () => { + const csv = exportDataService.toCsv([{ a: '=SUM(A1)', b: '@cmd' }], ['a', 'b']); + expect(csv).toBe("a,b\n'=SUM(A1),'@cmd"); + }); + + it('renders null and undefined as empty cells', () => { + expect(exportDataService.toCsv([{ a: null }], ['a', 'b'])).toBe('a,b\n,'); + }); + + it('downloads a CSV file via an anchor element', () => { + const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + exportDataService.exportToCsv([{ a: 1 }], ['a'], 'out.csv'); + expect(click).toHaveBeenCalled(); + click.mockRestore(); + }); +}); diff --git a/services/adminRefundService.ts b/services/adminRefundService.ts new file mode 100644 index 00000000..e38844e9 --- /dev/null +++ b/services/adminRefundService.ts @@ -0,0 +1,108 @@ +import api from '@/lib/api'; + +export type RefundStatus = 'pending' | 'reviewed' | 'approved' | 'rejected' | 'completed'; + +export interface RefundRecord { + id: string; + shipmentId: string; + customerId: string; + amount: number; + currency: string; + status: RefundStatus; + reason: string; + createdAt: string; + updatedAt: string; +} + +export interface RefundFilters { + status?: RefundStatus | 'all'; + dateFrom?: string; + dateTo?: string; + search?: string; +} + +export interface RefundPagination { + nextCursor: string | null; + total: number; + limit: number; +} + +export interface RefundListData { + refunds: RefundRecord[]; + pagination: RefundPagination; +} + +export interface BulkReviewResult { + successCount: number; + errorCount: number; +} + +export interface RefundTimelineEvent { + eventId: string; + action: string; + description: string; + timestamp: string; + actorId: string; + actorRole?: string; + transactionHash?: string; +} + +export interface RefundApiResponse { + success: boolean; + message?: string; + data?: T; +} + +const DEFAULT_PAGE_SIZE = 20; + +/** + * adminRefundService — admin refund processing API communication. + * Follows the Strict Layered Architecture: Component -> Hook -> Service. + */ +export const adminRefundService = { + /** Builds the query params sent to the backend; empty filters are omitted. */ + buildParams(filters: RefundFilters, cursor?: string | null, limit = DEFAULT_PAGE_SIZE) { + const params: Record = { limit }; + if (filters.status && filters.status !== 'all') params.status = filters.status; + if (filters.dateFrom) params.dateFrom = filters.dateFrom; + if (filters.dateTo) params.dateTo = filters.dateTo; + if (filters.search?.trim()) params.search = filters.search.trim(); + if (cursor) params.cursor = cursor; + return params; + }, + + async getRefunds( + filters: RefundFilters, + cursor?: string | null, + limit = DEFAULT_PAGE_SIZE + ): Promise { + const { data } = await api.get>('/admin/refunds', { + params: adminRefundService.buildParams(filters, cursor, limit), + }); + if (!data.success || !data.data) { + throw new Error(data.message ?? 'Failed to fetch refunds'); + } + return data.data; + }, + + async bulkMarkReviewed(ids: string[]): Promise { + const { data } = await api.post>( + '/admin/refunds/bulk-review', + { ids } + ); + if (!data.success || !data.data) { + throw new Error(data.message ?? 'Failed to mark refunds as reviewed'); + } + return data.data; + }, + + async getRefundTimeline(refundId: string): Promise { + const { data } = await api.get>( + `/admin/refunds/${encodeURIComponent(refundId)}/timeline` + ); + if (!data.success || !data.data) { + throw new Error(data.message ?? 'Failed to fetch refund timeline'); + } + return data.data.events; + }, +}; diff --git a/services/exportDataService.ts b/services/exportDataService.ts index 00c390ec..e07a9b32 100644 --- a/services/exportDataService.ts +++ b/services/exportDataService.ts @@ -55,4 +55,39 @@ export const exportDataService = { throw new Error(error instanceof Error ? error.message : 'Failed to generate download'); } }, + /** + * Serialises rows to RFC 4180 CSV. Values starting with a formula character + * are prefixed with a quote to prevent spreadsheet formula injection. + */ + toCsv(rows: Record[], columns: string[]): string { + const escape = (value: unknown): string => { + let text = value === null || value === undefined ? '' : String(value); + if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; + }; + const header = columns.map(escape).join(','); + const lines = rows.map((row) => columns.map((c) => escape(row[c])).join(',')); + return [header, ...lines].join('\n'); + }, + + /** + * Exports the given rows as a downloadable CSV file. + */ + exportToCsv( + rows: Record[], + columns: string[], + filename: string = 'swiftchain-export.csv' + ): void { + const blob = new Blob([exportDataService.toCsv(rows, columns)], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, }; diff --git a/services/kycService.ts b/services/kycService.ts index fcac050a..e6540f22 100644 --- a/services/kycService.ts +++ b/services/kycService.ts @@ -124,4 +124,16 @@ export const kycService = { throw error; } }, + + /** + * Submit the uploaded onboarding documents for admin approval + * @param documentIds - IDs of the uploaded onboarding documents + * @returns Promise with submission response + */ + async submitForApproval(documentIds: string[]): Promise> { + const { data } = await axios.post>(`${API_BASE_URL}/api/kyc/submit`, { + documentIds, + }); + return data; + }, }; From 996fd25484e0013a0fe4b776e9c21ea02b7f20df Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Sun, 18 Oct 2026 20:09:47 -0700 Subject: [PATCH 2/2] test(admin): mock both offset and client sizes for virtualized refund table The installed virtual-core reads offsetHeight/offsetWidth to size the scroll container, while earlier versions read clientHeight/clientWidth. Mock both so the table renders its row window in jsdom regardless of the version. --- components/admin/__tests__/AdminRefundViews.test.tsx | 9 +++++++-- components/admin/refunds/RefundTableView.tsx | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/components/admin/__tests__/AdminRefundViews.test.tsx b/components/admin/__tests__/AdminRefundViews.test.tsx index 5042270e..a995746b 100644 --- a/components/admin/__tests__/AdminRefundViews.test.tsx +++ b/components/admin/__tests__/AdminRefundViews.test.tsx @@ -47,8 +47,13 @@ const refunds = [ beforeAll(() => { // jsdom has no layout; give the virtualized scroll container a size. - Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 400 }); - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 800 }); + // Different virtual-core versions measure offset* or client* sizes; mock both. + for (const prop of ['offsetHeight', 'clientHeight'] as const) { + Object.defineProperty(HTMLElement.prototype, prop, { configurable: true, value: 400 }); + } + for (const prop of ['offsetWidth', 'clientWidth'] as const) { + Object.defineProperty(HTMLElement.prototype, prop, { configurable: true, value: 800 }); + } }); beforeEach(() => { diff --git a/components/admin/refunds/RefundTableView.tsx b/components/admin/refunds/RefundTableView.tsx index ae02bd4d..e559334d 100644 --- a/components/admin/refunds/RefundTableView.tsx +++ b/components/admin/refunds/RefundTableView.tsx @@ -18,6 +18,7 @@ interface RefundViewProps { } const ROW_HEIGHT = 44; +const SCROLL_HEIGHT = 384; /** Table view: virtualized rows so only the visible window is rendered. */ export function RefundTableView({ onSelectRefund }: RefundViewProps) { @@ -56,7 +57,7 @@ export function RefundTableView({ onSelectRefund }: RefundViewProps) { ))} -
+
{virtualizer.getVirtualItems().map((item) => { const refund = data.refunds[item.index];