From e8ef48d7174fdf206079c82a38a8c2e910f569eb Mon Sep 17 00:00:00 2001 From: emmanard Date: Mon, 1 Jun 2026 09:50:24 +0100 Subject: [PATCH 1/3] feat: add empty states for transactions and bills (#175) Shared EmptyState with illustrations on dashboard, bills, and onramp flows. --- app/dashboard/transactions/page.tsx | 19 ++ components/bills/bills-page-client.tsx | 10 +- components/bills/category-grid.tsx | 15 +- components/bills/category-page-client.tsx | 36 ++-- components/bills/recent-billers.tsx | 38 +++- components/bills/recent-payments.tsx | 107 ++++++++++ components/bills/scheduled-payments.tsx | 23 +-- components/bills/transaction-stats.tsx | 4 + components/dashboard/transaction-history.tsx | 193 ++++++------------ .../dashboard/transactions-page-client.tsx | 36 ++++ .../illustrations/empty-illustrations.tsx | 143 +++++++++++++ components/onramp/recent-transactions.tsx | 84 +++++--- components/ui/empty-state.tsx | 119 +++++++++++ hooks/use-bills-data.ts | 7 +- lib/dashboard/mock-transactions.ts | 121 +++++++++++ lib/mock-empty.ts | 13 ++ 16 files changed, 769 insertions(+), 199 deletions(-) create mode 100644 app/dashboard/transactions/page.tsx create mode 100644 components/bills/recent-payments.tsx create mode 100644 components/dashboard/transactions-page-client.tsx create mode 100644 components/illustrations/empty-illustrations.tsx create mode 100644 components/ui/empty-state.tsx create mode 100644 lib/dashboard/mock-transactions.ts create mode 100644 lib/mock-empty.ts diff --git a/app/dashboard/transactions/page.tsx b/app/dashboard/transactions/page.tsx new file mode 100644 index 0000000..cf0ec91 --- /dev/null +++ b/app/dashboard/transactions/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import { TransactionsPageClient } from '@/components/dashboard/transactions-page-client' + +export default function DashboardTransactionsPage() { + return ( + +
+
+

Loading transactions...

+
+
+ } + > + +
+ ) +} diff --git a/components/bills/bills-page-client.tsx b/components/bills/bills-page-client.tsx index d2cd0ef..df3fafe 100644 --- a/components/bills/bills-page-client.tsx +++ b/components/bills/bills-page-client.tsx @@ -15,6 +15,7 @@ import { RecentBillers } from '@/components/bills/recent-billers' import { ScheduledPayments } from '@/components/bills/scheduled-payments' import { TransactionStats } from '@/components/bills/transaction-stats' +import { RecentPayments } from '@/components/bills/recent-payments' import { useBillsData } from '@/hooks/use-bills-data' @@ -115,6 +116,8 @@ export function BillsPageClient() { {/* Stats Overview */} + + {/* Category Grid */} {/* Recent Billers */} - + setSearchQuery('')} + /> {/* Scheduled Payments */} diff --git a/components/bills/category-grid.tsx b/components/bills/category-grid.tsx index 5797502..5d157bb 100644 --- a/components/bills/category-grid.tsx +++ b/components/bills/category-grid.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' import { CategoryIcon } from '@/components/bills/biller-icons' +import { EmptyState } from '@/components/ui/empty-state' interface BillCategory { id: string @@ -39,11 +40,15 @@ export function CategoryGrid({ categories, searchQuery, selectedCountry }: Categ if (filteredCategories.length === 0 && searchQuery) { return ( -
-
- No categories found matching "{searchQuery}" -
-
+
+

Categories

+ +
) } diff --git a/components/bills/category-page-client.tsx b/components/bills/category-page-client.tsx index fddc1bd..5de970c 100644 --- a/components/bills/category-page-client.tsx +++ b/components/bills/category-page-client.tsx @@ -3,7 +3,8 @@ import { useState, useMemo } from 'react' import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' -import { ArrowLeft, Search, Filter, AlertCircle } from 'lucide-react' +import { ArrowLeft, Search, Filter } from 'lucide-react' +import { EmptyState } from '@/components/ui/empty-state' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -121,22 +122,23 @@ export function CategoryPageClient({ categorySlug }: CategoryPageClientProps) { ))} ) : ( - -
- -
-

No billers found

-

- We couldn't find any {category?.name.toLowerCase()} providers matching " - {searchQuery}" in this country. -

- + + setSearchQuery('') } + : { label: 'Back to bills', href: '/bills', variant: 'outline' } + } + bordered={false} + className="py-16 border-2 border-dashed border-border rounded-3xl bg-muted/20" + /> )} diff --git a/components/bills/recent-billers.tsx b/components/bills/recent-billers.tsx index bdfe54e..faca37f 100644 --- a/components/bills/recent-billers.tsx +++ b/components/bills/recent-billers.tsx @@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Clock, Star } from 'lucide-react' import { BillerIcon } from '@/components/bills/biller-icons' +import { EmptyState } from '@/components/ui/empty-state' interface Biller { id: string @@ -21,9 +22,10 @@ interface RecentBillersProps { billers: Biller[] searchQuery: string loading: boolean + onClearSearch?: () => void } -export function RecentBillers({ billers, searchQuery, loading }: RecentBillersProps) { +export function RecentBillers({ billers, searchQuery, loading, onClearSearch }: RecentBillersProps) { const [recentBillers, setRecentBillers] = useState([]) useEffect(() => { @@ -67,14 +69,32 @@ export function RecentBillers({ billers, searchQuery, loading }: RecentBillersPr if (filteredBillers.length === 0 && searchQuery) { return (
-
-

Recent Billers

-
-
-
- No billers found matching "{searchQuery}" -
-
+

Recent Billers

+ +
+ ) + } + + if (billers.length === 0) { + return ( +
+

Recent Billers

+
) } diff --git a/components/bills/recent-payments.tsx b/components/bills/recent-payments.tsx new file mode 100644 index 0000000..c3354c6 --- /dev/null +++ b/components/bills/recent-payments.tsx @@ -0,0 +1,107 @@ +'use client' + +import { motion } from 'framer-motion' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { EmptyState } from '@/components/ui/empty-state' +import { cn } from '@/lib/utils' +import type { BillsTransaction } from '@/hooks/use-bills-data' + +interface RecentPaymentsProps { + transactions: BillsTransaction[] + loading: boolean +} + +const statusStyles: Record = { + completed: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + pending: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + failed: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', +} + +export function RecentPayments({ transactions, loading }: RecentPaymentsProps) { + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( + + +
+
+ + + ))} +
+
+ ) + } + + if (transactions.length === 0) { + return ( +
+

Recent Bill Payments

+ +
+ ) + } + + return ( +
+
+

Recent Bill Payments

+ + {transactions.length} payment{transactions.length === 1 ? '' : 's'} + +
+ +
+ {transactions.map((tx, index) => ( + + + +
+

{tx.biller}

+

{tx.accountLabel}

+

+ {new Date(tx.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
+ + ₦{tx.amount.toLocaleString()} + + + {tx.status} + +
+
+
+
+ ))} +
+
+ ) +} diff --git a/components/bills/scheduled-payments.tsx b/components/bills/scheduled-payments.tsx index 52bceee..966f203 100644 --- a/components/bills/scheduled-payments.tsx +++ b/components/bills/scheduled-payments.tsx @@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Calendar, Pause, Play, MoreHorizontal } from 'lucide-react' +import { EmptyState } from '@/components/ui/empty-state' import { cn } from '@/lib/utils' interface ScheduledPayment { @@ -50,21 +51,13 @@ export function ScheduledPayments({ payments, loading }: ScheduledPaymentsProps) if (payments.length === 0) { return (
-
-

Scheduled Payments

-
- - - -

No scheduled payments

-

- Set up recurring payments to automate your bills -

- -
-
+

Scheduled Payments

+
) } diff --git a/components/bills/transaction-stats.tsx b/components/bills/transaction-stats.tsx index 968515a..fb76338 100644 --- a/components/bills/transaction-stats.tsx +++ b/components/bills/transaction-stats.tsx @@ -30,6 +30,10 @@ export function TransactionStats({ transactions, loading }: TransactionStatsProp ) } + if (transactions.length === 0) { + return null + } + const totalSpent = transactions .filter((t) => t.status === 'completed') .reduce((sum, t) => sum + t.amount, 0) diff --git a/components/dashboard/transaction-history.tsx b/components/dashboard/transaction-history.tsx index 4f0ea3c..7137028 100644 --- a/components/dashboard/transaction-history.tsx +++ b/components/dashboard/transaction-history.tsx @@ -17,17 +17,14 @@ import { } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/ui/empty-state' +import { + getDashboardTransactions, + type DashboardTransaction, +} from '@/lib/dashboard/mock-transactions' import { cn } from '@/lib/utils' -interface Transaction { - id: string - date: string - type: 'onramp' | 'offramp' | 'billpay' - amount: number - asset: string - counterparty: string - status: 'pending' | 'completed' | 'failed' -} +type Transaction = DashboardTransaction type SortField = 'date' | 'type' | 'asset' | 'amount' | 'status' type SortDirection = 'asc' | 'desc' @@ -35,108 +32,6 @@ type QuickFilter = 'all' | 'onramp' | 'offramp' | 'billpay' | 'failed' const PAGE_SIZE = 5 -const mockTransactions: Transaction[] = [ - { - id: 'ONR-240191', - date: '2026-02-26T08:22:00.000Z', - type: 'onramp', - amount: 15000, - asset: 'cNGN', - counterparty: 'From Zenith Bank', - status: 'completed', - }, - { - id: 'OFF-240180', - date: '2026-02-26T07:40:00.000Z', - type: 'offramp', - amount: 8700, - asset: 'USDC', - counterparty: 'To MTN Mobile Money', - status: 'pending', - }, - { - id: 'BIL-240178', - date: '2026-02-25T16:11:00.000Z', - type: 'billpay', - amount: 5500, - asset: 'cNGN', - counterparty: 'To IKEDC Electricity', - status: 'completed', - }, - { - id: 'ONR-240173', - date: '2026-02-25T11:35:00.000Z', - type: 'onramp', - amount: 25000, - asset: 'cNGN', - counterparty: 'From Access Bank', - status: 'pending', - }, - { - id: 'OFF-240166', - date: '2026-02-24T19:02:00.000Z', - type: 'offramp', - amount: 12000, - asset: 'USDT', - counterparty: 'To Kuda Bank', - status: 'completed', - }, - { - id: 'BIL-240162', - date: '2026-02-24T09:43:00.000Z', - type: 'billpay', - amount: 2100, - asset: 'cNGN', - counterparty: 'To Glo Airtime', - status: 'failed', - }, - { - id: 'ONR-240158', - date: '2026-02-23T20:10:00.000Z', - type: 'onramp', - amount: 8000, - asset: 'cNGN', - counterparty: 'From GTBank', - status: 'completed', - }, - { - id: 'BIL-240151', - date: '2026-02-23T08:37:00.000Z', - type: 'billpay', - amount: 4300, - asset: 'cNGN', - counterparty: 'To DSTV', - status: 'completed', - }, - { - id: 'OFF-240144', - date: '2026-02-22T22:29:00.000Z', - type: 'offramp', - amount: 16000, - asset: 'USDC', - counterparty: 'To Opay Wallet', - status: 'completed', - }, - { - id: 'ONR-240132', - date: '2026-02-22T10:04:00.000Z', - type: 'onramp', - amount: 10000, - asset: 'cNGN', - counterparty: 'From Moniepoint', - status: 'failed', - }, - { - id: 'OFF-240120', - date: '2026-02-21T14:18:00.000Z', - type: 'offramp', - amount: 7300, - asset: 'USDT', - counterparty: 'To First Bank', - status: 'completed', - }, -] - const typeConfig: Record< Transaction['type'], { label: string; icon: typeof ArrowDown; iconClassName: string } @@ -271,7 +166,12 @@ function Pagination({ ) } -export function TransactionHistory() { +interface TransactionHistoryProps { + transactions?: DashboardTransaction[] +} + +export function TransactionHistory({ transactions: transactionsOverride }: TransactionHistoryProps) { + const sourceTransactions = transactionsOverride ?? getDashboardTransactions() const [quickFilter, setQuickFilter] = useState('all') const [sortField, setSortField] = useState('date') const [sortDirection, setSortDirection] = useState('desc') @@ -289,10 +189,10 @@ export function TransactionHistory() { ] const filteredTransactions = useMemo(() => { - if (quickFilter === 'all') return mockTransactions - if (quickFilter === 'failed') return mockTransactions.filter((tx) => tx.status === 'failed') - return mockTransactions.filter((tx) => tx.type === quickFilter) - }, [quickFilter]) + if (quickFilter === 'all') return sourceTransactions + if (quickFilter === 'failed') return sourceTransactions.filter((tx) => tx.status === 'failed') + return sourceTransactions.filter((tx) => tx.type === quickFilter) + }, [quickFilter, sourceTransactions]) const sortedTransactions = useMemo(() => { const valueByStatus: Record = { @@ -394,6 +294,41 @@ export function TransactionHistory() { return } + const hasNoTransactions = sourceTransactions.length === 0 + const hasNoFilteredResults = !hasNoTransactions && sortedTransactions.length === 0 + + const renderEmptyState = () => { + if (hasNoFilteredResults) { + return ( + onFilterChange('all'), + }} + bordered={false} + /> + ) + } + + return ( + + ) + } + return ( + {hasNoTransactions || hasNoFilteredResults ? ( +
{renderEmptyState()}
+ ) : ( + <>
@@ -603,15 +542,19 @@ export function TransactionHistory() { ) })} - -
- -
+ + )} + + {sortedTransactions.length > 0 && ( +
+ +
+ )} ) } diff --git a/components/dashboard/transactions-page-client.tsx b/components/dashboard/transactions-page-client.tsx new file mode 100644 index 0000000..5ada95b --- /dev/null +++ b/components/dashboard/transactions-page-client.tsx @@ -0,0 +1,36 @@ +'use client' + +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' +import { DashboardLayout } from '@/components/dashboard/dashboard-layout' +import { TransactionHistory } from '@/components/dashboard/transaction-history' +import { FilterPanel } from '@/components/transactions/FilterPanel' + +export function TransactionsPageClient() { + return ( + +
+
+
+ + + Back to Dashboard + +
+

Transactions

+

+ All onramp, offramp, and bill payment activity in one place +

+
+
+ +
+ + +
+
+ ) +} diff --git a/components/illustrations/empty-illustrations.tsx b/components/illustrations/empty-illustrations.tsx new file mode 100644 index 0000000..b9082b0 --- /dev/null +++ b/components/illustrations/empty-illustrations.tsx @@ -0,0 +1,143 @@ +import { cn } from '@/lib/utils' + +const illustrationClass = 'h-auto w-full max-w-[200px]' + +export function TransactionsEmptyIllustration({ className }: { className?: string }) { + return ( + + ) +} + +export function BillsEmptyIllustration({ className }: { className?: string }) { + return ( + + ) +} + +export function SearchEmptyIllustration({ className }: { className?: string }) { + return ( + + ) +} + +export function ScheduledEmptyIllustration({ className }: { className?: string }) { + return ( + + ) +} + +export function FilterEmptyIllustration({ className }: { className?: string }) { + return ( + + ) +} diff --git a/components/onramp/recent-transactions.tsx b/components/onramp/recent-transactions.tsx index 2198c4b..fe8dece 100644 --- a/components/onramp/recent-transactions.tsx +++ b/components/onramp/recent-transactions.tsx @@ -1,9 +1,11 @@ 'use client' import { cn } from '@/lib/utils' +import { isMockEmptyEnabled } from '@/lib/mock-empty' +import { EmptyState } from '@/components/ui/empty-state' import type { TransactionItem } from '@/types/onramp' -const transactions: TransactionItem[] = [ +const defaultTransactions: TransactionItem[] = [ { id: 'tx-1', fromAmount: '₦25,000', @@ -33,36 +35,68 @@ const statusClass: Record = { Failed: 'bg-destructive/10 text-destructive', } -export function RecentTransactions() { +interface RecentTransactionsProps { + transactions?: TransactionItem[] +} + +function getTransactions(override?: TransactionItem[]): TransactionItem[] { + if (override !== undefined) return override + if (isMockEmptyEnabled()) return [] + return defaultTransactions +} + +export function RecentTransactions({ transactions: transactionsOverride }: RecentTransactionsProps) { + const transactions = getTransactions(transactionsOverride) + return (

Recent Onramp Transactions

- + {transactions.length > 0 ? ( + + ) : null}
-
- {transactions.map((tx) => ( -
-
- {tx.fromAmount} → {tx.toAmount} -
- + +
+ ) : ( +
+ {transactions.map((tx) => ( +
- {tx.status} - -
{tx.timeLabel}
-
- ))} -
+
+ {tx.fromAmount} → {tx.toAmount} +
+ + {tx.status} + +
{tx.timeLabel}
+
+ ))} + + )}
) } diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx new file mode 100644 index 0000000..7f0b4dc --- /dev/null +++ b/components/ui/empty-state.tsx @@ -0,0 +1,119 @@ +'use client' + +import Link from 'next/link' +import { motion } from 'framer-motion' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import { + BillsEmptyIllustration, + FilterEmptyIllustration, + ScheduledEmptyIllustration, + SearchEmptyIllustration, + TransactionsEmptyIllustration, +} from '@/components/illustrations/empty-illustrations' + +export type EmptyStateVariant = 'transactions' | 'bills' | 'search' | 'scheduled' | 'filter' + +const variantIllustrations: Record = { + transactions: , + bills: , + search: , + scheduled: , + filter: , +} + +export interface EmptyStateAction { + label: string + href?: string + onClick?: () => void + variant?: 'default' | 'outline' | 'secondary' | 'ghost' +} + +export interface EmptyStateProps { + variant?: EmptyStateVariant + title: string + description?: string + illustration?: React.ReactNode + action?: EmptyStateAction + secondaryAction?: EmptyStateAction + className?: string + compact?: boolean + bordered?: boolean +} + +function ActionButton({ action }: { action: EmptyStateAction }) { + const variant = action.variant ?? (action.href ? 'default' : 'outline') + + if (action.href) { + return ( + + ) + } + + return ( + + ) +} + +export function EmptyState({ + variant = 'transactions', + title, + description, + illustration, + action, + secondaryAction, + className, + compact = false, + bordered = true, +}: EmptyStateProps) { + const art = illustration ?? variantIllustrations[variant] + + const content = ( + +
{art}
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} + {(action || secondaryAction) && ( +
+ {action ? : null} + {secondaryAction ? : null} +
+ )} +
+ ) + + if (!bordered) { + return content + } + + return ( + + {content} + + ) +} diff --git a/hooks/use-bills-data.ts b/hooks/use-bills-data.ts index 9af3426..cdb47cc 100644 --- a/hooks/use-bills-data.ts +++ b/hooks/use-bills-data.ts @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import { isMockEmptyEnabled } from '@/lib/mock-empty' export interface BillCategory { id: string @@ -264,11 +265,13 @@ export function useBillsData(countryCode: string): UseBillsDataReturn { // In a real app, you would fetch data based on countryCode // For now, we return mock data + const useEmpty = isMockEmptyEnabled() + return { categories: MOCK_CATEGORIES, recentBillers: MOCK_BILLERS, - transactions: MOCK_TRANSACTIONS, - scheduledPayments: MOCK_SCHEDULED_PAYMENTS, + transactions: useEmpty ? [] : MOCK_TRANSACTIONS, + scheduledPayments: useEmpty ? [] : MOCK_SCHEDULED_PAYMENTS, loading, } } diff --git a/lib/dashboard/mock-transactions.ts b/lib/dashboard/mock-transactions.ts new file mode 100644 index 0000000..556d95a --- /dev/null +++ b/lib/dashboard/mock-transactions.ts @@ -0,0 +1,121 @@ +import { isMockEmptyEnabled } from '@/lib/mock-empty' + +export interface DashboardTransaction { + id: string + date: string + type: 'onramp' | 'offramp' | 'billpay' + amount: number + asset: string + counterparty: string + status: 'pending' | 'completed' | 'failed' +} + +export const MOCK_DASHBOARD_TRANSACTIONS: DashboardTransaction[] = [ + { + id: 'ONR-240191', + date: '2026-02-26T08:22:00.000Z', + type: 'onramp', + amount: 15000, + asset: 'cNGN', + counterparty: 'From Zenith Bank', + status: 'completed', + }, + { + id: 'OFF-240180', + date: '2026-02-26T07:40:00.000Z', + type: 'offramp', + amount: 8700, + asset: 'USDC', + counterparty: 'To MTN Mobile Money', + status: 'pending', + }, + { + id: 'BIL-240178', + date: '2026-02-25T16:11:00.000Z', + type: 'billpay', + amount: 5500, + asset: 'cNGN', + counterparty: 'To IKEDC Electricity', + status: 'completed', + }, + { + id: 'ONR-240173', + date: '2026-02-25T11:35:00.000Z', + type: 'onramp', + amount: 25000, + asset: 'cNGN', + counterparty: 'From Access Bank', + status: 'pending', + }, + { + id: 'OFF-240166', + date: '2026-02-24T19:02:00.000Z', + type: 'offramp', + amount: 12000, + asset: 'USDT', + counterparty: 'To Kuda Bank', + status: 'completed', + }, + { + id: 'BIL-240162', + date: '2026-02-24T09:43:00.000Z', + type: 'billpay', + amount: 2100, + asset: 'cNGN', + counterparty: 'To Glo Airtime', + status: 'failed', + }, + { + id: 'ONR-240158', + date: '2026-02-23T20:10:00.000Z', + type: 'onramp', + amount: 8000, + asset: 'cNGN', + counterparty: 'From GTBank', + status: 'completed', + }, + { + id: 'BIL-240151', + date: '2026-02-23T08:37:00.000Z', + type: 'billpay', + amount: 4300, + asset: 'cNGN', + counterparty: 'To DSTV', + status: 'completed', + }, + { + id: 'OFF-240144', + date: '2026-02-22T22:29:00.000Z', + type: 'offramp', + amount: 16000, + asset: 'USDC', + counterparty: 'To Opay Wallet', + status: 'completed', + }, + { + id: 'ONR-240132', + date: '2026-02-22T10:04:00.000Z', + type: 'onramp', + amount: 10000, + asset: 'cNGN', + counterparty: 'From Moniepoint', + status: 'failed', + }, + { + id: 'OFF-240120', + date: '2026-02-21T14:18:00.000Z', + type: 'offramp', + amount: 7300, + asset: 'USDT', + counterparty: 'To First Bank', + status: 'completed', + }, +] + +export function getDashboardTransactions( + override?: DashboardTransaction[] +): DashboardTransaction[] { + if (override !== undefined) return override + if (isMockEmptyEnabled()) return [] + return MOCK_DASHBOARD_TRANSACTIONS +} diff --git a/lib/mock-empty.ts b/lib/mock-empty.ts new file mode 100644 index 0000000..f8a1e93 --- /dev/null +++ b/lib/mock-empty.ts @@ -0,0 +1,13 @@ +/** Dev/demo helper: empty lists when ?empty=1 or NEXT_PUBLIC_MOCK_EMPTY=true */ +export function isMockEmptyEnabled(): boolean { + if (typeof window !== 'undefined') { + try { + if (new URLSearchParams(window.location.search).get('empty') === '1') { + return true + } + } catch { + // ignore + } + } + return process.env.NEXT_PUBLIC_MOCK_EMPTY === 'true' +} From 881291a64a50da3f796749f8f225f63655b3acd1 Mon Sep 17 00:00:00 2001 From: emmanard Date: Mon, 1 Jun 2026 10:04:59 +0100 Subject: [PATCH 2/3] fix: unblock pre-push tests after upstream merge Fix withdrawal lock release and payment test token cache resets. --- jest.config.js | 1 + lib/kyc/withdrawalLimitService.ts | 13 +++++++++---- lib/payments/__tests__/mpesa.test.ts | 3 ++- lib/payments/__tests__/mtn-momo.test.ts | 3 ++- lib/payments/mpesa.ts | 11 ++++++++--- lib/payments/mtn-momo.ts | 5 +++++ 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/jest.config.js b/jest.config.js index d723614..5ce576d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,6 +30,7 @@ const customJestConfig = { }, }, testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/', '/.next/', '/helpcenter/'], } module.exports = createJestConfig(customJestConfig) diff --git a/lib/kyc/withdrawalLimitService.ts b/lib/kyc/withdrawalLimitService.ts index 26b8ea4..ab371e5 100644 --- a/lib/kyc/withdrawalLimitService.ts +++ b/lib/kyc/withdrawalLimitService.ts @@ -66,20 +66,25 @@ export const _withdrawalStore = new Map() const _userLocks = new Map>() function _acquireLock(userId: string): { release: () => void; ready: Promise } { - let release!: () => void + let releaseFn: (() => void) | null = null const ready = new Promise((resolve) => { const prev = _userLocks.get(userId) ?? Promise.resolve() _userLocks.set( userId, prev.then(() => { resolve() - return new Promise((r) => { - release = r + return new Promise((resolveRelease) => { + releaseFn = resolveRelease }) }) ) }) - return { ready, release } + return { + ready, + release: () => { + releaseFn?.() + }, + } } // --------------------------------------------------------------------------- diff --git a/lib/payments/__tests__/mpesa.test.ts b/lib/payments/__tests__/mpesa.test.ts index 89d24bd..ef35ec4 100644 --- a/lib/payments/__tests__/mpesa.test.ts +++ b/lib/payments/__tests__/mpesa.test.ts @@ -2,7 +2,7 @@ * Tests for the M-Pesa STK Push integration. */ -import { MpesaProvider } from '../mpesa' +import { MpesaProvider, resetMpesaTokenCacheForTests } from '../mpesa' import { MobileMoneyError } from '../types' // --------------------------------------------------------------------------- @@ -38,6 +38,7 @@ beforeEach(() => { process.env.MPESA_SHORTCODE = '174379' process.env.MPESA_PASSKEY = 'test-passkey' process.env.MPESA_ENV = 'sandbox' + resetMpesaTokenCacheForTests() jest.useFakeTimers() }) diff --git a/lib/payments/__tests__/mtn-momo.test.ts b/lib/payments/__tests__/mtn-momo.test.ts index 9789056..2da4ccb 100644 --- a/lib/payments/__tests__/mtn-momo.test.ts +++ b/lib/payments/__tests__/mtn-momo.test.ts @@ -2,7 +2,7 @@ * Tests for the MTN MoMo Collections API integration. */ -import { MtnMomoProvider } from '../mtn-momo' +import { MtnMomoProvider, resetMtnMomoTokenCacheForTests } from '../mtn-momo' import { MobileMoneyError } from '../types' // --------------------------------------------------------------------------- @@ -37,6 +37,7 @@ beforeEach(() => { process.env.MTN_MOMO_API_USER = '550e8400-e29b-41d4-a716-446655440000' process.env.MTN_MOMO_API_KEY = 'test-api-key' process.env.MTN_MOMO_ENV = 'sandbox' + resetMtnMomoTokenCacheForTests() }) afterEach(() => { diff --git a/lib/payments/mpesa.ts b/lib/payments/mpesa.ts index 64f546b..b481bab 100644 --- a/lib/payments/mpesa.ts +++ b/lib/payments/mpesa.ts @@ -41,6 +41,11 @@ interface TokenCache { let tokenCache: TokenCache | null = null +/** Clears cached OAuth token (for tests). */ +export function resetMpesaTokenCacheForTests(): void { + tokenCache = null +} + async function fetchAccessToken(): Promise { const now = Date.now() if (tokenCache && tokenCache.expiresAt > now + 30_000) { @@ -226,7 +231,7 @@ async function pollForResult(checkoutRequestId: string): Promise // ResponseCode 0 means the query itself succeeded; ResultCode is the payment outcome if (result.ResponseCode === '0') { - const status = mapResultCode(result.ResultCode) + const status = mapResultCode(String(result.ResultCode)) if (status !== 'PENDING') { return status } @@ -273,11 +278,11 @@ export class MpesaProvider implements MobileMoneyProvider { async getStatus(transactionId: string): Promise { const result = await queryStkStatus(transactionId) - if (result.ResponseCode !== '0') { + if (String(result.ResponseCode) !== '0') { return 'PENDING' } - const status = mapResultCode(result.ResultCode) + const status = mapResultCode(String(result.ResultCode)) if (status === 'CANCELLED') { throw new MobileMoneyError('CANCELLED', result.ResultDesc) diff --git a/lib/payments/mtn-momo.ts b/lib/payments/mtn-momo.ts index 131897d..de70425 100644 --- a/lib/payments/mtn-momo.ts +++ b/lib/payments/mtn-momo.ts @@ -41,6 +41,11 @@ interface TokenCache { let tokenCache: TokenCache | null = null +/** Clears cached OAuth token (for tests). */ +export function resetMtnMomoTokenCacheForTests(): void { + tokenCache = null +} + async function fetchAccessToken(): Promise { const now = Date.now() if (tokenCache && tokenCache.expiresAt > now + 30_000) { From 3eeee1f4ac6c493adac51e15b161b02cc05e7f66 Mon Sep 17 00:00:00 2001 From: emmanard Date: Mon, 1 Jun 2026 10:04:59 +0100 Subject: [PATCH 3/3] fix: unblock pre-push tests after upstream merge Fix withdrawal lock release and payment test token cache resets. --- jest.config.js | 1 + lib/kyc/withdrawalLimitService.ts | 13 +++++++++---- lib/payments/__tests__/mpesa.test.ts | 3 ++- lib/payments/__tests__/mtn-momo.test.ts | 3 ++- lib/payments/mpesa.ts | 11 ++++++++--- lib/payments/mtn-momo.ts | 5 +++++ 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/jest.config.js b/jest.config.js index d723614..5ce576d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,6 +30,7 @@ const customJestConfig = { }, }, testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: ['/node_modules/', '/.next/', '/helpcenter/'], } module.exports = createJestConfig(customJestConfig) diff --git a/lib/kyc/withdrawalLimitService.ts b/lib/kyc/withdrawalLimitService.ts index 26b8ea4..ab371e5 100644 --- a/lib/kyc/withdrawalLimitService.ts +++ b/lib/kyc/withdrawalLimitService.ts @@ -66,20 +66,25 @@ export const _withdrawalStore = new Map() const _userLocks = new Map>() function _acquireLock(userId: string): { release: () => void; ready: Promise } { - let release!: () => void + let releaseFn: (() => void) | null = null const ready = new Promise((resolve) => { const prev = _userLocks.get(userId) ?? Promise.resolve() _userLocks.set( userId, prev.then(() => { resolve() - return new Promise((r) => { - release = r + return new Promise((resolveRelease) => { + releaseFn = resolveRelease }) }) ) }) - return { ready, release } + return { + ready, + release: () => { + releaseFn?.() + }, + } } // --------------------------------------------------------------------------- diff --git a/lib/payments/__tests__/mpesa.test.ts b/lib/payments/__tests__/mpesa.test.ts index 89d24bd..ef35ec4 100644 --- a/lib/payments/__tests__/mpesa.test.ts +++ b/lib/payments/__tests__/mpesa.test.ts @@ -2,7 +2,7 @@ * Tests for the M-Pesa STK Push integration. */ -import { MpesaProvider } from '../mpesa' +import { MpesaProvider, resetMpesaTokenCacheForTests } from '../mpesa' import { MobileMoneyError } from '../types' // --------------------------------------------------------------------------- @@ -38,6 +38,7 @@ beforeEach(() => { process.env.MPESA_SHORTCODE = '174379' process.env.MPESA_PASSKEY = 'test-passkey' process.env.MPESA_ENV = 'sandbox' + resetMpesaTokenCacheForTests() jest.useFakeTimers() }) diff --git a/lib/payments/__tests__/mtn-momo.test.ts b/lib/payments/__tests__/mtn-momo.test.ts index 9789056..2da4ccb 100644 --- a/lib/payments/__tests__/mtn-momo.test.ts +++ b/lib/payments/__tests__/mtn-momo.test.ts @@ -2,7 +2,7 @@ * Tests for the MTN MoMo Collections API integration. */ -import { MtnMomoProvider } from '../mtn-momo' +import { MtnMomoProvider, resetMtnMomoTokenCacheForTests } from '../mtn-momo' import { MobileMoneyError } from '../types' // --------------------------------------------------------------------------- @@ -37,6 +37,7 @@ beforeEach(() => { process.env.MTN_MOMO_API_USER = '550e8400-e29b-41d4-a716-446655440000' process.env.MTN_MOMO_API_KEY = 'test-api-key' process.env.MTN_MOMO_ENV = 'sandbox' + resetMtnMomoTokenCacheForTests() }) afterEach(() => { diff --git a/lib/payments/mpesa.ts b/lib/payments/mpesa.ts index 64f546b..b481bab 100644 --- a/lib/payments/mpesa.ts +++ b/lib/payments/mpesa.ts @@ -41,6 +41,11 @@ interface TokenCache { let tokenCache: TokenCache | null = null +/** Clears cached OAuth token (for tests). */ +export function resetMpesaTokenCacheForTests(): void { + tokenCache = null +} + async function fetchAccessToken(): Promise { const now = Date.now() if (tokenCache && tokenCache.expiresAt > now + 30_000) { @@ -226,7 +231,7 @@ async function pollForResult(checkoutRequestId: string): Promise // ResponseCode 0 means the query itself succeeded; ResultCode is the payment outcome if (result.ResponseCode === '0') { - const status = mapResultCode(result.ResultCode) + const status = mapResultCode(String(result.ResultCode)) if (status !== 'PENDING') { return status } @@ -273,11 +278,11 @@ export class MpesaProvider implements MobileMoneyProvider { async getStatus(transactionId: string): Promise { const result = await queryStkStatus(transactionId) - if (result.ResponseCode !== '0') { + if (String(result.ResponseCode) !== '0') { return 'PENDING' } - const status = mapResultCode(result.ResultCode) + const status = mapResultCode(String(result.ResultCode)) if (status === 'CANCELLED') { throw new MobileMoneyError('CANCELLED', result.ResultDesc) diff --git a/lib/payments/mtn-momo.ts b/lib/payments/mtn-momo.ts index 131897d..de70425 100644 --- a/lib/payments/mtn-momo.ts +++ b/lib/payments/mtn-momo.ts @@ -41,6 +41,11 @@ interface TokenCache { let tokenCache: TokenCache | null = null +/** Clears cached OAuth token (for tests). */ +export function resetMtnMomoTokenCacheForTests(): void { + tokenCache = null +} + async function fetchAccessToken(): Promise { const now = Date.now() if (tokenCache && tokenCache.expiresAt > now + 30_000) {