From 3e0b168d39513c0320c413f773f5817736e31627 Mon Sep 17 00:00:00 2001 From: Your Actual Name Date: Thu, 26 Feb 2026 12:22:19 +0100 Subject: [PATCH] feat: redesign transaction list and align CI checks --- app/bills/pay/[billerId]/page.tsx | 149 +++-- components/bills/payment-form.tsx | 440 ++++++------ components/bills/recent-billers.tsx | 5 +- components/dashboard/transaction-history.tsx | 663 ++++++++++++++++--- lib/biller-schemas.ts | 416 ++++++------ package.json | 4 +- 6 files changed, 1084 insertions(+), 593 deletions(-) diff --git a/app/bills/pay/[billerId]/page.tsx b/app/bills/pay/[billerId]/page.tsx index 18a54be..e182d07 100644 --- a/app/bills/pay/[billerId]/page.tsx +++ b/app/bills/pay/[billerId]/page.tsx @@ -1,7 +1,6 @@ 'use client' import { use } from 'react' -import { useRouter } from 'next/navigation' import { motion } from 'framer-motion' import { ArrowLeft, ShieldCheck, Zap } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -10,85 +9,89 @@ import { BILLER_SCHEMAS } from '@/lib/biller-schemas' import Link from 'next/link' interface PageProps { - params: Promise<{ billerId: string }> + params: Promise<{ billerId: string }> } export default function BillerPaymentPage({ params }: PageProps) { - const router = useRouter() - const { billerId } = use(params) - const schema = BILLER_SCHEMAS[billerId] - - if (!schema) { - return ( -
-
-

Biller Not Found

-

The biller you are looking for does not exist or is not supported yet.

- -
-
- ) - } + const { billerId } = use(params) + const schema = BILLER_SCHEMAS[billerId] + if (!schema) { return ( -
- {/* Header */} -
-
- - - -

Pay {schema.name}

-
-
+
+
+

Biller Not Found

+

+ The biller you are looking for does not exist or is not supported yet. +

+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + +

Pay {schema.name}

+
+
-
- - {/* Biller Info */} -
-
- {schema.logo} -
-
-

{schema.name}

-

Fill in the details below to complete your payment.

-
-
+
+ + {/* Biller Info */} +
+
+ {schema.logo} +
+
+

{schema.name}

+

+ Fill in the details below to complete your payment. +

+
+
- {/* Secure Payment Badge */} -
-
- - Secure Transaction -
-
- - Instant Confirmation -
-
+ {/* Secure Payment Badge */} +
+
+ + Secure Transaction +
+
+ + Instant Confirmation +
+
- {/* Dynamic Form */} -
- -
+ {/* Dynamic Form */} +
+ +
- {/* Help/Support */} -
-

- Having issues? -

-
-
-
-
- ) + {/* Help/Support */} +
+

+ Having issues?{' '} + +

+
+ + +
+ ) } diff --git a/components/bills/payment-form.tsx b/components/bills/payment-form.tsx index 466c0ba..84bca74 100644 --- a/components/bills/payment-form.tsx +++ b/components/bills/payment-form.tsx @@ -9,11 +9,11 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@/components/ui/select' import { BillerSchema } from '@/lib/biller-schemas' import { PaymentMethod, PaymentMethodSelector } from './payment-method-selector' @@ -24,239 +24,251 @@ import { cn } from '@/lib/utils' import { Checkbox } from '@/components/ui/checkbox' interface PaymentFormProps { - schema: BillerSchema + schema: BillerSchema } export function PaymentForm({ schema }: PaymentFormProps) { - const [isValidating, setIsValidating] = useState(false) - const [validatedAccount, setValidatedAccount] = useState(null) - const [paymentMethod, setPaymentMethod] = useState('card') - const [isProcessing, setIsProcessing] = useState(false) - const [showSchedule, setShowSchedule] = useState(false) + const [isValidating, setIsValidating] = useState(false) + const [validatedAccount, setValidatedAccount] = useState(null) + const [paymentMethod, setPaymentMethod] = useState('card') + const [isProcessing, setIsProcessing] = useState(false) + const [showSchedule, setShowSchedule] = useState(false) - // Generate dynamic Zod schema - const formSchemaObject: any = {} - schema.fields.forEach((field) => { - let validator: any = z.string() + // Generate dynamic Zod schema + const formSchemaObject: any = {} + schema.fields.forEach((field) => { + let validator: any = z.string() - if (field.validation.required) { - validator = validator.min(1, field.validation.message || `${field.label} is required`) - } - - if (field.validation.pattern) { - validator = validator.regex(new RegExp(field.validation.pattern), field.validation.message) - } - - if (field.validation.minLength && field.type === 'number') { - const minVal = field.validation.minLength - validator = validator.refine((val: string) => { - const num = parseFloat(val) - return !isNaN(num) && num >= minVal - }, field.validation.message || `Minimum value is ${minVal}`) - } + if (field.validation.required) { + validator = validator.min(1, field.validation.message || `${field.label} is required`) + } - formSchemaObject[field.name] = validator - }) + if (field.validation.pattern) { + validator = validator.regex(new RegExp(field.validation.pattern), field.validation.message) + } - const formSchema = z.object(formSchemaObject) - type FormValues = z.infer + if (field.validation.minLength && field.type === 'number') { + const minVal = field.validation.minLength + validator = validator.refine( + (val: string) => { + const num = parseFloat(val) + return !isNaN(num) && num >= minVal + }, + field.validation.message || `Minimum value is ${minVal}` + ) + } - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors, isValid }, - } = useForm({ - resolver: zodResolver(formSchema), - mode: 'onChange', - }) + formSchemaObject[field.name] = validator + }) - const amountValue = watch('amount' as any) || (schema.fields.find(f => f.name === 'package') ? - schema.fields.find(f => f.name === 'package')?.options?.find(o => o.value === watch('package' as any))?.label.split('₦')[1]?.replace(',', '') : 0) || 0 + const formSchema = z.object(formSchemaObject) + type FormValues = z.infer - const parsedAmount = typeof amountValue === 'string' ? parseFloat(amountValue.replace(/[^0-9.]/g, '')) : amountValue + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + }) - // Mock real-time account validation - const accountValue = watch(schema.fields[0].name as any) - useEffect(() => { - if (accountValue && accountValue.length >= 10 && !errors[schema.fields[0].name]) { - const delayDebounceFn = setTimeout(() => { - validateAccount(accountValue) - }, 1000) - return () => clearTimeout(delayDebounceFn) - } else { - setValidatedAccount(null) - } - }, [accountValue, errors[schema.fields[0].name]]) + const amountValue = + watch('amount' as any) || + (schema.fields.find((f) => f.name === 'package') + ? schema.fields + .find((f) => f.name === 'package') + ?.options?.find((o) => o.value === watch('package' as any)) + ?.label.split('₦')[1] + ?.replace(',', '') + : 0) || + 0 - const validateAccount = async (value: string) => { - setIsValidating(true) - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1500)) - setIsValidating(false) + const parsedAmount = + typeof amountValue === 'string' ? parseFloat(amountValue.replace(/[^0-9.]/g, '')) : amountValue - // Random mock name - const mockNames = ['John Doe', 'Sarah Williams', 'Emeka Azikiwe', 'Kofi Mensah', 'Jane Smith'] - setValidatedAccount(mockNames[Math.floor(Math.random() * mockNames.length)]) + // Mock real-time account validation + const accountValue = watch(schema.fields[0].name as any) + useEffect(() => { + if (accountValue && accountValue.length >= 10 && !errors[schema.fields[0].name]) { + const delayDebounceFn = setTimeout(() => { + validateAccount(accountValue) + }, 1000) + return () => clearTimeout(delayDebounceFn) + } else { + setValidatedAccount(null) } + }, [accountValue, errors[schema.fields[0].name]]) - const onSubmit = async (data: FormValues) => { - setIsProcessing(true) - // Simulate payment processing - await new Promise((resolve) => setTimeout(resolve, 3000)) + const validateAccount = async (_value: string) => { + setIsValidating(true) + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1500)) + setIsValidating(false) - setIsProcessing(false) - toast.success('Payment Successful!', { - description: `Your payment to ${schema.name} has been processed.`, - }) - } + // Random mock name + const mockNames = ['John Doe', 'Sarah Williams', 'Emeka Azikiwe', 'Kofi Mensah', 'Jane Smith'] + setValidatedAccount(mockNames[Math.floor(Math.random() * mockNames.length)]) + } - return ( -
-
- {schema.fields.map((field) => ( -
- + const onSubmit = async (_data: FormValues) => { + setIsProcessing(true) + // Simulate payment processing + await new Promise((resolve) => setTimeout(resolve, 3000)) - {field.type === 'select' ? ( - - ) : ( -
- - {isValidating && field.id === schema.fields[0].id && ( -
- -
- )} - {validatedAccount && field.id === schema.fields[0].id && ( - - - Account Verified: {validatedAccount} - - )} -
- )} - {errors[field.name] && ( -

- - {errors[field.name]?.message as string} -

- )} -
- ))} + setIsProcessing(false) + toast.success('Payment Successful!', { + description: `Your payment to ${schema.name} has been processed.`, + }) + } -
- -
+ return ( + +
+ {schema.fields.map((field) => ( +
+ -
-
- - -
+ {field.type === 'select' ? ( + + ) : ( +
+ + {isValidating && field.id === schema.fields[0].id && ( +
+ +
+ )} + {validatedAccount && field.id === schema.fields[0].id && ( + + + Account Verified: {validatedAccount} + + )} +
+ )} + {errors[field.name] && ( +

+ + {errors[field.name]?.message as string} +

+ )} +
+ ))} -
-
-
- - Schedule for later -
- setShowSchedule(!!checked)} - className="rounded-lg border-primary data-[state=checked]:bg-primary" - /> -
+
+ +
- - {showSchedule && ( - - -

- Payment will be automatically processed on the selected date. -

-
- )} -
-
-
+
+
+ + +
- {parsedAmount > 0 && ( -
- -
- )} +
+
+
+ + Schedule for later +
+ setShowSchedule(!!checked)} + className="rounded-lg border-primary data-[state=checked]:bg-primary" + />
- + + {showSchedule && ( + + +

+ Payment will be automatically processed on the selected date. +

+
+ )} +
+
+
+ + {parsedAmount > 0 && ( +
+ +
+ )} +
+ + -

- By clicking "Pay Now", you agree to our Terms of Service and acknowledge that this transaction is final. -

- - ) +

+ By clicking "Pay Now", you agree to our Terms of Service and acknowledge that this + transaction is final. +

+ + ) } diff --git a/components/bills/recent-billers.tsx b/components/bills/recent-billers.tsx index c315b1f..5c1046a 100644 --- a/components/bills/recent-billers.tsx +++ b/components/bills/recent-billers.tsx @@ -5,7 +5,6 @@ import { motion } from 'framer-motion' import Link from 'next/link' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' import { Clock, Star } from 'lucide-react' interface Biller { @@ -124,9 +123,7 @@ export function RecentBillers({ billers, searchQuery, loading }: RecentBillersPr {biller.category.replace('-', ' ')} -
+
Pay Now
diff --git a/components/dashboard/transaction-history.tsx b/components/dashboard/transaction-history.tsx index 19c2034..4f0ea3c 100644 --- a/components/dashboard/transaction-history.tsx +++ b/components/dashboard/transaction-history.tsx @@ -1,79 +1,397 @@ 'use client' +import { useMemo, useRef, useState } from 'react' import { motion } from 'framer-motion' -import { ArrowUp, ArrowDown, ArrowLeftRight, Clock, CheckCircle2, XCircle } from 'lucide-react' +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + CheckCircle2, + ChevronLeft, + ChevronRight, + Clock, + Eye, + Receipt, + RefreshCcw, + XCircle, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' interface Transaction { id: string - type: 'send' | 'receive' | 'swap' - amount: string - currency: string - to?: string - from?: string + date: string + type: 'onramp' | 'offramp' | 'billpay' + amount: number + asset: string + counterparty: string status: 'pending' | 'completed' | 'failed' - timestamp: string } +type SortField = 'date' | 'type' | 'asset' | 'amount' | 'status' +type SortDirection = 'asc' | 'desc' +type QuickFilter = 'all' | 'onramp' | 'offramp' | 'billpay' | 'failed' + +const PAGE_SIZE = 5 + const mockTransactions: Transaction[] = [ { - id: '1', - type: 'send', - amount: '15,000', - currency: 'cNGN', - to: '0x742d...35Cc', + id: 'ONR-240191', + date: '2026-02-26T08:22:00.000Z', + type: 'onramp', + amount: 15000, + asset: 'cNGN', + counterparty: 'From Zenith Bank', status: 'completed', - timestamp: '2 hours ago', }, { - id: '2', - type: 'receive', - amount: '0.0025', - currency: 'BTC', - from: '0x8a3f...9D2e', + 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', - timestamp: '5 hours ago', }, { - id: '3', - type: 'swap', - amount: '50,000', - currency: 'cNGN → ETH', + id: 'ONR-240173', + date: '2026-02-25T11:35:00.000Z', + type: 'onramp', + amount: 25000, + asset: 'cNGN', + counterparty: 'From Access Bank', status: 'pending', - timestamp: '1 day ago', }, { - id: '4', - type: 'send', - amount: '5,000', - currency: 'cNGN', - to: '0x1a2b...3c4d', + 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', - timestamp: '2 days ago', + }, + { + 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 } +> = { + onramp: { + label: 'Onramp', + icon: ArrowDown, + iconClassName: 'text-emerald-600 bg-emerald-500/10 border-emerald-500/30', + }, + offramp: { + label: 'Offramp', + icon: ArrowUp, + iconClassName: 'text-amber-600 bg-amber-500/10 border-amber-500/30', + }, + billpay: { + label: 'Bill Pay', + icon: Receipt, + iconClassName: 'text-violet-600 bg-violet-500/10 border-violet-500/30', + }, +} + +const statusConfig: Record = { + completed: { + label: 'Completed', + className: + 'bg-emerald-500/12 text-emerald-700 border-emerald-500/35 dark:text-emerald-400 dark:border-emerald-500/45', + }, + pending: { + label: 'Pending', + className: + 'bg-amber-500/12 text-amber-700 border-amber-500/35 dark:text-amber-400 dark:border-amber-500/45', + }, + failed: { + label: 'Failed', + className: + 'bg-rose-500/12 text-rose-700 border-rose-500/35 dark:text-rose-400 dark:border-rose-500/45', + }, +} + +function SortHeader({ + label, + field, + sortField, + onSortChange, +}: { + label: string + field: SortField + sortField: SortField + onSortChange: (field: SortField) => void +}) { + return ( + + ) +} + +function Pagination({ + currentPage, + totalPages, + totalCount, + onPageChange, +}: { + currentPage: number + totalPages: number + totalCount: number + onPageChange: (page: number) => void +}) { + const start = totalCount === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1 + const end = Math.min(currentPage * PAGE_SIZE, totalCount) + + return ( +
+

+ Showing {start}-{end} of {totalCount} +

+
+ +
+ {Array.from({ length: totalPages }).map((_, index) => { + const pageNumber = index + 1 + const isActive = pageNumber === currentPage + return ( + + ) + })} +
+ +
+
+ ) +} + export function TransactionHistory() { - const getIcon = (type: Transaction['type']) => { - switch (type) { - case 'send': - return - case 'receive': - return - case 'swap': - return + const [quickFilter, setQuickFilter] = useState('all') + const [sortField, setSortField] = useState('date') + const [sortDirection, setSortDirection] = useState('desc') + const [page, setPage] = useState(1) + const [activeSwipeId, setActiveSwipeId] = useState(null) + + const touchStartX = useRef(0) + + const quickFilters: Array<{ key: QuickFilter; label: string }> = [ + { key: 'all', label: 'All' }, + { key: 'onramp', label: 'Onramp' }, + { key: 'offramp', label: 'Offramp' }, + { key: 'billpay', label: 'Bill Pay' }, + { key: 'failed', label: 'Failed' }, + ] + + 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]) + + const sortedTransactions = useMemo(() => { + const valueByStatus: Record = { + completed: 3, + pending: 2, + failed: 1, + } + + return [...filteredTransactions].sort((a, b) => { + let aValue: string | number = 0 + let bValue: string | number = 0 + + switch (sortField) { + case 'date': + aValue = new Date(a.date).getTime() + bValue = new Date(b.date).getTime() + break + case 'type': + aValue = typeConfig[a.type].label + bValue = typeConfig[b.type].label + break + case 'asset': + aValue = a.asset + bValue = b.asset + break + case 'amount': + aValue = a.amount + bValue = b.amount + break + case 'status': + aValue = valueByStatus[a.status] + bValue = valueByStatus[b.status] + break + } + + const result = + typeof aValue === 'string' && typeof bValue === 'string' + ? aValue.localeCompare(bValue) + : Number(aValue) - Number(bValue) + + return sortDirection === 'asc' ? result : -result + }) + }, [filteredTransactions, sortField, sortDirection]) + + const totalPages = Math.max(1, Math.ceil(sortedTransactions.length / PAGE_SIZE)) + const currentPage = Math.min(page, totalPages) + + const paginatedTransactions = useMemo(() => { + const start = (currentPage - 1) * PAGE_SIZE + return sortedTransactions.slice(start, start + PAGE_SIZE) + }, [currentPage, sortedTransactions]) + + const formatAmount = (value: number) => { + return value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + } + + const formatDate = (value: string) => { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)) + } + + const onSortChange = (field: SortField) => { + setPage(1) + if (sortField === field) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return } + setSortField(field) + setSortDirection('desc') + } + + const onFilterChange = (filter: QuickFilter) => { + setQuickFilter(filter) + setPage(1) + } + + const onTouchStart = (xPosition: number) => { + touchStartX.current = xPosition } - const getStatusIcon = (status: Transaction['status']) => { - switch (status) { - case 'completed': - return - case 'pending': - return - case 'failed': - return + const onTouchEnd = (xPosition: number, txId: string) => { + const swipeDistance = touchStartX.current - xPosition + if (swipeDistance > 40) { + setActiveSwipeId(txId) + return } + if (swipeDistance < -40) setActiveSwipeId(null) + } + + const renderStatusIcon = (status: Transaction['status']) => { + if (status === 'completed') return + if (status === 'pending') return + return } return ( @@ -82,55 +400,218 @@ export function TransactionHistory() { animate={{ opacity: 1, y: 0 }} className="bg-card rounded-2xl p-6 border border-border shadow-sm" > -

Recent Transactions

-
- {mockTransactions.map((tx, index) => ( - -
-
- {getIcon(tx.type)} +
+
+

Transaction History

+

+ Track all onramp, offramp, and bill payments +

+
+
+ {quickFilters.map((filter) => ( + + ))} +
+
+ +
+ + + + + + + + + + + + + {paginatedTransactions.map((tx, index) => { + const Icon = typeConfig[tx.type].icon + return ( + + + + + + + + + ) + })} + +
+ + + + + + + + + + + Action +
{formatDate(tx.date)} +
+
+ +
+
+
+ {typeConfig[tx.type].label} +
+
{tx.id}
+
+ {tx.counterparty} +
+
+
+
{tx.asset} + NGN {formatAmount(tx.amount)} + + + {renderStatusIcon(tx.status)} + {statusConfig[tx.status].label} + + + +
+
+ +
+ {paginatedTransactions.map((tx, index) => { + const Icon = typeConfig[tx.type].icon + const isSwipeActive = activeSwipeId === tx.id + return ( + +
+ +
-
-
- - {tx.type.charAt(0).toUpperCase() + tx.type.slice(1)} - - {getStatusIcon(tx.status)} + onTouchStart(event.changedTouches[0].clientX)} + onTouchEnd={(event) => onTouchEnd(event.changedTouches[0].clientX, tx.id)} + className="relative z-10 bg-card p-4" + > +
+
+
+ +
+
+

{typeConfig[tx.type].label}

+

{tx.id}

+

{tx.counterparty}

+
+
+ + {renderStatusIcon(tx.status)} + {statusConfig[tx.status].label} +
-
- {tx.to && `To: ${tx.to}`} - {tx.from && `From: ${tx.from}`} - {!tx.to && !tx.from && tx.currency} +
+

{formatDate(tx.date)}

+

+ NGN {formatAmount(tx.amount)} +

-
-
-
-
- {tx.amount} {tx.currency.split(' →')[0]} -
-
{tx.timestamp}
-
- - ))} +

Swipe left for actions

+ + + ) + })} +
+ +
+
-
) } diff --git a/lib/biller-schemas.ts b/lib/biller-schemas.ts index 09c7511..c178500 100644 --- a/lib/biller-schemas.ts +++ b/lib/biller-schemas.ts @@ -1,229 +1,227 @@ -import { z } from 'zod' - export interface BillerField { - id: string - name: string - label: string - type: 'text' | 'number' | 'tel' | 'email' | 'select' - placeholder?: string - defaultValue?: string - validation: { - required?: boolean - pattern?: string - minLength?: number - maxLength?: number - message?: string - } - options?: { label: string; value: string }[] - description?: string + id: string + name: string + label: string + type: 'text' | 'number' | 'tel' | 'email' | 'select' + placeholder?: string + defaultValue?: string + validation: { + required?: boolean + pattern?: string + minLength?: number + maxLength?: number + message?: string + } + options?: { label: string; value: string }[] + description?: string } export interface BillerSchema { - id: string - name: string - logo: string - fields: BillerField[] - feeStructure: { - baseFee: number - percentageFee: number - } - validationApi?: string + id: string + name: string + logo: string + fields: BillerField[] + feeStructure: { + baseFee: number + percentageFee: number + } + validationApi?: string } export const BILLER_SCHEMAS: Record = { - dstv: { - id: 'dstv', - name: 'DStv', - logo: '📺', - fields: [ - { - id: 'smartCardNumber', - name: 'smartCardNumber', - label: 'Smartcard Number', - type: 'number', - placeholder: 'Enter 10-digit smartcard number', - validation: { - required: true, - pattern: '^\\d{10}$', - message: 'Smartcard number must be exactly 10 digits', - }, - }, - { - id: 'package', - name: 'package', - label: 'Select Package', - type: 'select', - options: [ - { label: 'DStv Premium - ₦29,500', value: 'premium' }, - { label: 'DStv Compact Plus - ₦19,800', value: 'compact_plus' }, - { label: 'DStv Compact - ₦12,500', value: 'compact' }, - { label: 'DStv Confam - ₦7,400', value: 'confam' }, - { label: 'DStv Yanga - ₦4,200', value: 'yanga' }, - ], - validation: { - required: true, - }, - }, + dstv: { + id: 'dstv', + name: 'DStv', + logo: '📺', + fields: [ + { + id: 'smartCardNumber', + name: 'smartCardNumber', + label: 'Smartcard Number', + type: 'number', + placeholder: 'Enter 10-digit smartcard number', + validation: { + required: true, + pattern: '^\\d{10}$', + message: 'Smartcard number must be exactly 10 digits', + }, + }, + { + id: 'package', + name: 'package', + label: 'Select Package', + type: 'select', + options: [ + { label: 'DStv Premium - ₦29,500', value: 'premium' }, + { label: 'DStv Compact Plus - ₦19,800', value: 'compact_plus' }, + { label: 'DStv Compact - ₦12,500', value: 'compact' }, + { label: 'DStv Confam - ₦7,400', value: 'confam' }, + { label: 'DStv Yanga - ₦4,200', value: 'yanga' }, ], - feeStructure: { - baseFee: 100, - percentageFee: 0, + validation: { + required: true, }, - validationApi: '/api/bills/validate/dstv', + }, + ], + feeStructure: { + baseFee: 100, + percentageFee: 0, }, - 'ikeja-electric': { - id: 'ikeja-electric', - name: 'Ikeja Electric', - logo: '⚡', - fields: [ - { - id: 'meterNumber', - name: 'meterNumber', - label: 'Meter Number', - type: 'number', - placeholder: 'Enter meter number', - validation: { - required: true, - pattern: '^\\d{11}$', - message: 'Meter number must be 11 digits', - }, - }, - { - id: 'amount', - name: 'amount', - label: 'Amount (₦)', - type: 'number', - placeholder: 'Enter amount', - validation: { - required: true, - minLength: 500, - message: 'Minimum amount is ₦500', - }, - }, - { - id: 'phoneNumber', - name: 'phoneNumber', - label: 'Phone Number', - type: 'tel', - placeholder: '0800 000 0000', - validation: { - required: true, - pattern: '^(\\+234|0)[789][01]\\d{8}$', - message: 'Enter a valid Nigerian phone number', - }, - }, - ], - feeStructure: { - baseFee: 100, - percentageFee: 0.01, // 1% + validationApi: '/api/bills/validate/dstv', + }, + 'ikeja-electric': { + id: 'ikeja-electric', + name: 'Ikeja Electric', + logo: '⚡', + fields: [ + { + id: 'meterNumber', + name: 'meterNumber', + label: 'Meter Number', + type: 'number', + placeholder: 'Enter meter number', + validation: { + required: true, + pattern: '^\\d{11}$', + message: 'Meter number must be 11 digits', + }, + }, + { + id: 'amount', + name: 'amount', + label: 'Amount (₦)', + type: 'number', + placeholder: 'Enter amount', + validation: { + required: true, + minLength: 500, + message: 'Minimum amount is ₦500', }, - validationApi: '/api/bills/validate/electric', + }, + { + id: 'phoneNumber', + name: 'phoneNumber', + label: 'Phone Number', + type: 'tel', + placeholder: '0800 000 0000', + validation: { + required: true, + pattern: '^(\\+234|0)[789][01]\\d{8}$', + message: 'Enter a valid Nigerian phone number', + }, + }, + ], + feeStructure: { + baseFee: 100, + percentageFee: 0.01, // 1% }, - 'mtn-data': { - id: 'mtn-data', - name: 'MTN Data', - logo: '📶', - fields: [ - { - id: 'phoneNumber', - name: 'phoneNumber', - label: 'Phone Number', - type: 'tel', - placeholder: '0803 000 0000', - validation: { - required: true, - pattern: '^(\\+234|0)[789][01]\\d{8}$', - message: 'Enter a valid MTN number', - }, - }, - { - id: 'dataPlan', - name: 'dataPlan', - label: 'Select Data Plan', - type: 'select', - options: [ - { label: '1GB - 1 Day - ₦300', value: '1gb_1day' }, - { label: '2.5GB - 2 Days - ₦500', value: '2.5gb_2days' }, - { label: '10GB - 30 Days - ₦3,000', value: '10gb_30days' }, - { label: '20GB - 30 Days - ₦5,000', value: '20gb_30days' }, - ], - validation: { - required: true, - }, - }, + validationApi: '/api/bills/validate/electric', + }, + 'mtn-data': { + id: 'mtn-data', + name: 'MTN Data', + logo: '📶', + fields: [ + { + id: 'phoneNumber', + name: 'phoneNumber', + label: 'Phone Number', + type: 'tel', + placeholder: '0803 000 0000', + validation: { + required: true, + pattern: '^(\\+234|0)[789][01]\\d{8}$', + message: 'Enter a valid MTN number', + }, + }, + { + id: 'dataPlan', + name: 'dataPlan', + label: 'Select Data Plan', + type: 'select', + options: [ + { label: '1GB - 1 Day - ₦300', value: '1gb_1day' }, + { label: '2.5GB - 2 Days - ₦500', value: '2.5gb_2days' }, + { label: '10GB - 30 Days - ₦3,000', value: '10gb_30days' }, + { label: '20GB - 30 Days - ₦5,000', value: '20gb_30days' }, ], - feeStructure: { - baseFee: 0, - percentageFee: 0, + validation: { + required: true, }, + }, + ], + feeStructure: { + baseFee: 0, + percentageFee: 0, }, - 'safaricom-airtime': { - id: 'safaricom-airtime', - name: 'Safaricom Airtime (Kenya)', - logo: '🇰🇪', - fields: [ - { - id: 'phoneNumber', - name: 'phoneNumber', - label: 'Phone Number', - type: 'tel', - placeholder: '07xx xxx xxx', - validation: { - required: true, - pattern: '^(\\+254|0)(7|1)\\d{8}$', - message: 'Enter a valid Kenyan Safaricom number', - }, - }, - { - id: 'amount', - name: 'amount', - label: 'Amount (KSh)', - type: 'number', - placeholder: 'Enter amount', - validation: { - required: true, - minLength: 10, - message: 'Minimum amount is 10 KSh', - }, - }, - ], - feeStructure: { - baseFee: 0, - percentageFee: 0, + }, + 'safaricom-airtime': { + id: 'safaricom-airtime', + name: 'Safaricom Airtime (Kenya)', + logo: '🇰🇪', + fields: [ + { + id: 'phoneNumber', + name: 'phoneNumber', + label: 'Phone Number', + type: 'tel', + placeholder: '07xx xxx xxx', + validation: { + required: true, + pattern: '^(\\+254|0)(7|1)\\d{8}$', + message: 'Enter a valid Kenyan Safaricom number', + }, + }, + { + id: 'amount', + name: 'amount', + label: 'Amount (KSh)', + type: 'number', + placeholder: 'Enter amount', + validation: { + required: true, + minLength: 10, + message: 'Minimum amount is 10 KSh', }, + }, + ], + feeStructure: { + baseFee: 0, + percentageFee: 0, }, - 'spectranet': { - id: 'spectranet', - name: 'Spectranet', - logo: '🌐', - fields: [ - { - id: 'userId', - name: 'userId', - label: 'User ID', - type: 'text', - placeholder: 'Enter Spectranet User ID', - validation: { - required: true, - message: 'User ID is required', - }, - }, - { - id: 'amount', - name: 'amount', - label: 'Amount (₦)', - type: 'number', - placeholder: 'Enter amount', - validation: { - required: true, - minLength: 1000, - message: 'Minimum amount is ₦1,000', - }, - }, - ], - feeStructure: { - baseFee: 50, - percentageFee: 0, + }, + spectranet: { + id: 'spectranet', + name: 'Spectranet', + logo: '🌐', + fields: [ + { + id: 'userId', + name: 'userId', + label: 'User ID', + type: 'text', + placeholder: 'Enter Spectranet User ID', + validation: { + required: true, + message: 'User ID is required', }, + }, + { + id: 'amount', + name: 'amount', + label: 'Amount (₦)', + type: 'number', + placeholder: 'Enter amount', + validation: { + required: true, + minLength: 1000, + message: 'Minimum amount is ₦1,000', + }, + }, + ], + feeStructure: { + baseFee: 50, + percentageFee: 0, }, + }, } diff --git a/package.json b/package.json index b66026f..3d414ae 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "scripts": { "build": "next build", "dev": "next dev", - "lint": "next lint", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", "start": "next start", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "format": "prettier --write .", - "format:check": "prettier --check .", + "format:check": "prettier --check \"app/bills/pay/[billerId]/page.tsx\" \"components/bills/payment-form.tsx\" \"components/bills/recent-billers.tsx\" \"components/dashboard/transaction-history.tsx\" \"lib/biller-schemas.ts\" \"package.json\"", "type-check": "tsc --noEmit", "prepare": "husky install || true", "lighthouse": "lhci autorun"