From aeb89b16074ecf1c4bc57df60c8b34e5eb21438b Mon Sep 17 00:00:00 2001 From: jobbykings Date: Mon, 30 Mar 2026 03:32:30 -0700 Subject: [PATCH] feat: implement comprehensive payment system - Add React Query hooks for payments (useInitiatePayment, useGetMyPayments) - Create PaystackPaymentButton component for inline payments - Implement member payments history page with filtering and pagination - Add PDF invoice generation provider with professional formatting - Add payments route to protected routes and navigation - Set up React Query provider and query keys structure - Update package.json with required dependencies Fixes #395, #396, #397, #398 --- app/backend/package.json | 2 + app/backend/src/app.module.ts | 2 + app/backend/src/invoices/invoices.module.ts | 8 + .../providers/pdf-invoice.provider.ts | 198 ++++++++++++++ app/frontend/app/layout.tsx | 5 +- app/frontend/app/payments/page.tsx | 257 ++++++++++++++++++ .../payments/PaystackPaymentButton.tsx | 130 +++++++++ app/frontend/config/route-guard.config.ts | 6 + app/frontend/lib/apiClient.ts | 46 ++++ .../lib/react-query/QueryClientProvider.tsx | 40 +++ .../hooks/payments/useGetMyPayments.ts | 61 +++++ .../hooks/payments/useInitiatePayment.ts | 41 +++ .../lib/react-query/keys/queryKeys.ts | 30 ++ app/frontend/package.json | 5 +- 14 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 app/backend/src/invoices/invoices.module.ts create mode 100644 app/backend/src/invoices/providers/pdf-invoice.provider.ts create mode 100644 app/frontend/app/payments/page.tsx create mode 100644 app/frontend/components/payments/PaystackPaymentButton.tsx create mode 100644 app/frontend/lib/apiClient.ts create mode 100644 app/frontend/lib/react-query/QueryClientProvider.tsx create mode 100644 app/frontend/lib/react-query/hooks/payments/useGetMyPayments.ts create mode 100644 app/frontend/lib/react-query/hooks/payments/useInitiatePayment.ts create mode 100644 app/frontend/lib/react-query/keys/queryKeys.ts diff --git a/app/backend/package.json b/app/backend/package.json index ddc98d17..313e18f1 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -91,6 +91,7 @@ "passport-oauth2": "^1.8.0", "passport-twitter": "^1.0.4", "redis": "^5.11.0", + "pdfkit": "^0.15.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "siwe": "^3.0.0", @@ -110,6 +111,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/nodemailer": "^6.4.14", + "@types/pdfkit": "^0.15.0", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index e17b18a0..700a782f 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -31,6 +31,7 @@ import { OrganizerModule } from './organizer/organizer.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { DataAggregationModule } from './data-aggregation/data-aggregation.module'; +import { InvoicesModule } from './invoices/invoices.module'; @Module({ imports: [ @@ -70,6 +71,7 @@ import { DataAggregationModule } from './data-aggregation/data-aggregation.modul WebhooksModule, WaitlistModule, DataAggregationModule, + InvoicesModule, ], controllers: [AppController, ApiController], providers: [AppService], diff --git a/app/backend/src/invoices/invoices.module.ts b/app/backend/src/invoices/invoices.module.ts new file mode 100644 index 00000000..c0832b78 --- /dev/null +++ b/app/backend/src/invoices/invoices.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PdfInvoiceProvider } from './providers/pdf-invoice.provider'; + +@Module({ + providers: [PdfInvoiceProvider], + exports: [PdfInvoiceProvider], +}) +export class InvoicesModule {} diff --git a/app/backend/src/invoices/providers/pdf-invoice.provider.ts b/app/backend/src/invoices/providers/pdf-invoice.provider.ts new file mode 100644 index 00000000..7f2e5365 --- /dev/null +++ b/app/backend/src/invoices/providers/pdf-invoice.provider.ts @@ -0,0 +1,198 @@ +import { Injectable } from '@nestjs/common'; +import * as PDFDocument from 'pdfkit'; + +export interface Invoice { + id: string; + invoiceNumber: string; + issueDate: Date; + paymentDate?: Date; + totalAmount: number; + status: 'PAID' | 'PENDING' | 'FAILED'; + user: { + id: string; + fullName: string; + email: string; + }; + booking: { + id: string; + startDate: Date; + endDate: Date; + seats: number; + workspace: { + name: string; + planType: string; + }; + }; +} + +@Injectable() +export class PdfInvoiceProvider { + async generate(invoice: Invoice): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: 'A4', + margins: { + top: 50, + bottom: 50, + left: 50, + right: 50, + }, + }); + + const buffers: Buffer[] = []; + + doc.on('data', (chunk) => { + buffers.push(chunk); + }); + + doc.on('end', () => { + const pdfBuffer = Buffer.concat(buffers); + resolve(pdfBuffer); + }); + + doc.on('error', (error) => { + reject(error); + }); + + // Header - Logo and Title + this.addHeader(doc); + + // Invoice Metadata + this.addInvoiceMetadata(doc, invoice); + + // Bill To Section + this.addBillToSection(doc, invoice); + + // Service Details + this.addServiceDetails(doc, invoice); + + // Amount Summary + this.addAmountSummary(doc, invoice); + + // Footer + this.addFooter(doc); + + doc.end(); + } catch (error) { + reject(error); + } + }); + } + + private addHeader(doc: typeof PDFDocument): void { + // Logo placeholder (in a real implementation, you'd add an actual logo) + doc.fillColor('#1f2937').fontSize(24).text('Gatherraa', 50, 50, { align: 'left' }); + + // TAX INVOICE title + doc.fillColor('#6b7280').fontSize(14).text('TAX INVOICE', 50, 80, { align: 'left' }); + + // Horizontal line + doc.strokeColor('#e5e7eb').lineWidth(1).moveTo(50, 100).lineTo(545, 100).stroke(); + } + + private addInvoiceMetadata(doc: typeof PDFDocument, invoice: Invoice): void { + const startY = 120; + + doc.fillColor('#374151').fontSize(12); + + // Left column - Invoice details + doc.text('Invoice Number:', 50, startY); + doc.text(`INV-${invoice.invoiceNumber}`, 150, startY); + + doc.text('Issue Date:', 50, startY + 20); + doc.text(invoice.issueDate.toLocaleDateString('en-NG'), 150, startY + 20); + + if (invoice.paymentDate) { + doc.text('Payment Date:', 50, startY + 40); + doc.text(invoice.paymentDate.toLocaleDateString('en-NG'), 150, startY + 40); + } + + // Right column - Status + doc.text('Status:', 400, startY); + + const statusColor = invoice.status === 'PAID' ? '#059669' : + invoice.status === 'PENDING' ? '#d97706' : '#dc2626'; + + doc.fillColor(statusColor).text(invoice.status, 450, startY); + doc.fillColor('#374151'); // Reset color + } + + private addBillToSection(doc: typeof PDFDocument, invoice: Invoice): void { + const startY = 200; + + doc.fillColor('#1f2937').fontSize(14).text('Bill To:', 50, startY); + + doc.fillColor('#6b7280').fontSize(12); + doc.text(invoice.user.fullName, 50, startY + 25); + doc.text(invoice.user.email, 50, startY + 45); + + // Horizontal line + doc.strokeColor('#e5e7eb').lineWidth(1).moveTo(50, startY + 70).lineTo(545, startY + 70).stroke(); + } + + private addServiceDetails(doc: typeof PDFDocument, invoice: Invoice): void { + const startY = 290; + + doc.fillColor('#1f2937').fontSize(14).text('Service Details:', 50, startY); + + doc.fillColor('#6b7280').fontSize(12); + + const details = [ + { label: 'Workspace Name:', value: invoice.booking.workspace.name }, + { label: 'Plan Type:', value: invoice.booking.workspace.planType }, + { label: 'Start Date:', value: invoice.booking.startDate.toLocaleDateString('en-NG') }, + { label: 'End Date:', value: invoice.booking.endDate.toLocaleDateString('en-NG') }, + { label: 'Number of Seats:', value: invoice.booking.seats.toString() }, + ]; + + details.forEach((detail, index) => { + const y = startY + 25 + (index * 20); + doc.text(detail.label, 50, y); + doc.text(detail.value, 150, y); + }); + + // Horizontal line + doc.strokeColor('#e5e7eb').lineWidth(1).moveTo(50, startY + 125).lineTo(545, startY + 125).stroke(); + } + + private addAmountSummary(doc: typeof PDFDocument, invoice: Invoice): void { + const startY = 435; + + doc.fillColor('#1f2937').fontSize(14).text('Amount Summary:', 50, startY); + + doc.fillColor('#6b7280').fontSize(12); + + // Subtotal (assuming no taxes for now) + doc.text('Subtotal:', 400, startY + 25); + doc.text(`₦${invoice.totalAmount.toFixed(2)}`, 480, startY + 25, { align: 'right' }); + + // Total + doc.fillColor('#1f2937').fontSize(14).font('Helvetica-Bold'); + doc.text('Total:', 400, startY + 50); + doc.text(`₦${invoice.totalAmount.toFixed(2)}`, 480, startY + 50, { align: 'right' }); + + // Status + doc.fillColor('#6b7280').fontSize(12).font('Helvetica'); + doc.text('Status:', 400, startY + 75); + + const statusColor = invoice.status === 'PAID' ? '#059669' : + invoice.status === 'PENDING' ? '#d97706' : '#dc2626'; + + doc.fillColor(statusColor).text(invoice.status, 450, startY + 75); + } + + private addFooter(doc: typeof PDFDocument): void { + const footerY = 650; + + // Horizontal line + doc.strokeColor('#e5e7eb').lineWidth(1).moveTo(50, footerY).lineTo(545, footerY).stroke(); + + // Thank you message + doc.fillColor('#6b7280').fontSize(12).text('Thank you for your business!', 50, footerY + 20, { align: 'center' }); + + // Company info + doc.fontSize(10).text('Gatherraa - Workspace Management Platform', 50, footerY + 40, { align: 'center' }); + doc.text('support@gatherraa.com | www.gatherraa.com', 50, footerY + 55, { align: 'center' }); + } +} diff --git a/app/frontend/app/layout.tsx b/app/frontend/app/layout.tsx index d0698c29..75bbad2d 100644 --- a/app/frontend/app/layout.tsx +++ b/app/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { WalletProvider } from "@/components/wallet/WalletProvider"; import { OfflineProvider } from "@/lib/offline/OfflineContext"; +import { ReactQueryProvider } from "@/lib/react-query/QueryClientProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -42,7 +43,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/app/frontend/app/payments/page.tsx b/app/frontend/app/payments/page.tsx new file mode 100644 index 00000000..22057d8a --- /dev/null +++ b/app/frontend/app/payments/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useState } from 'react'; +import { DashboardLayout } from '../../components/dashboard/DashboardLayout'; +import { useGetMyPayments } from '../../lib/react-query/hooks/payments/useGetMyPayments'; +import { PaystackPaymentButton } from '../../components/payments/PaystackPaymentButton'; +import { + CreditCard, + CheckCircle, + Clock, + XCircle, + Calendar, + Building, + ArrowLeftRight, + ChevronLeft, + ChevronRight +} from 'lucide-react'; + +type PaymentStatus = 'ALL' | 'SUCCESS' | 'PENDING' | 'FAILED'; + +const statusConfig = { + SUCCESS: { + label: 'Success', + color: 'text-green-600 bg-green-50 border-green-200', + icon: CheckCircle, + }, + PENDING: { + label: 'Pending', + color: 'text-amber-600 bg-amber-50 border-amber-200', + icon: Clock, + }, + FAILED: { + label: 'Failed', + color: 'text-red-600 bg-red-50 border-red-200', + icon: XCircle, + }, +}; + +const statusTabs: PaymentStatus[] = ['ALL', 'SUCCESS', 'PENDING', 'FAILED']; + +export default function PaymentsPage() { + const [activeStatus, setActiveStatus] = useState('ALL'); + const [currentPage, setCurrentPage] = useState(1); + + const statusFilter = activeStatus === 'ALL' ? undefined : activeStatus; + + const { data: paymentsData, isLoading, error } = useGetMyPayments({ + page: currentPage, + limit: 10, + status: statusFilter, + }); + + const payments = paymentsData?.data || []; + const meta = paymentsData?.meta; + + const handleStatusChange = (status: PaymentStatus) => { + setActiveStatus(status); + setCurrentPage(1); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-NG', { + style: 'currency', + currency: 'NGN', + }).format(amount / 100); // Convert from kobo to naira + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-NG', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const getStatusBadge = (status: string) => { + const config = statusConfig[status as keyof typeof statusConfig]; + if (!config) return null; + + const Icon = config.icon; + return ( + + + {config.label} + + ); + }; + + return ( + +
+ {/* Header */} +
+

+ Payment History +

+

+ View and manage all your payment transactions +

+
+ + {/* Status Filter Tabs */} +
+ {statusTabs.map((status) => ( + + ))} +
+ + {/* Loading State */} + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Error State */} + {error && ( +
+

+ Failed to load payments. Please try again later. +

+
+ )} + + {/* Empty State */} + {!isLoading && !error && payments.length === 0 && ( +
+ +

+ No payments found +

+

+ {activeStatus === 'ALL' + ? "You haven't made any payments yet." + : `No ${activeStatus.toLowerCase()} payments found.` + } +

+
+ )} + + {/* Payments List */} + {!isLoading && !error && payments.length > 0 && ( +
+ {payments.map((payment) => ( +
+
+
+
+ + + {payment.booking.workspace.name} + +
+
+ Ref: {payment.reference} +
+
+
+ {getStatusBadge(payment.status)} +
+ {formatCurrency(payment.amount)} +
+
+
+ +
+
+
+ + {formatDate(payment.createdAt)} +
+
+ + {formatDate(payment.booking.startDate)} - {formatDate(payment.booking.endDate)} +
+
+ + {/* Pay Now button for confirmed bookings without successful payment */} + {payment.status === 'PENDING' && ( + { + // Refresh the payments list + window.location.reload(); + }} + className="text-sm" + /> + )} +
+
+ ))} +
+ )} + + {/* Pagination */} + {!isLoading && !error && meta && meta.totalPages > 1 && ( +
+ + + + Page {currentPage} of {meta.totalPages} + + + +
+ )} +
+
+ ); +} diff --git a/app/frontend/components/payments/PaystackPaymentButton.tsx b/app/frontend/components/payments/PaystackPaymentButton.tsx new file mode 100644 index 00000000..ff2be8bc --- /dev/null +++ b/app/frontend/components/payments/PaystackPaymentButton.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useInitiatePayment } from '../lib/react-query/hooks/payments/useInitiatePayment'; +import { CreditCard, Loader2 } from 'lucide-react'; + +interface PaystackPaymentButtonProps { + bookingId: string; + amountKobo: number; + onSuccess?: () => void; + onClose?: () => void; + className?: string; + disabled?: boolean; +} + +declare global { + interface Window { + PaystackPop: { + setup: (options: { + key: string; + email: string; + amount: number; + ref: string; + onSuccess: (response: any) => void; + onCancel: () => void; + }) => { + openIframe: () => void; + close: () => void; + }; + }; + } +} + +export function PaystackPaymentButton({ + bookingId, + amountKobo, + onSuccess, + onClose, + className = '', + disabled = false, +}: PaystackPaymentButtonProps) { + const [scriptLoaded, setScriptLoaded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const initiatePayment = useInitiatePayment(); + + const PAYSTACK_PUBLIC_KEY = process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY; + + // Mock user email - in a real app, this would come from auth context + const userEmail = 'user@example.com'; + + useEffect(() => { + // Load Paystack script + const script = document.createElement('script'); + script.src = 'https://js.paystack.co/v1/inline.js'; + script.async = true; + script.onload = () => setScriptLoaded(true); + script.onerror = () => { + console.error('Failed to load Paystack script'); + setScriptLoaded(false); + }; + document.body.appendChild(script); + + return () => { + document.body.removeChild(script); + }; + }, []); + + const handlePayment = async () => { + if (!scriptLoaded || !PAYSTACK_PUBLIC_KEY) { + console.error('Paystack script not loaded or public key missing'); + return; + } + + setIsLoading(true); + + try { + const paymentData = await initiatePayment.mutateAsync({ bookingId }); + + if (!window.PaystackPop) { + throw new Error('PaystackPop not available'); + } + + const paystack = window.PaystackPop.setup({ + key: PAYSTACK_PUBLIC_KEY, + email: userEmail, + amount: amountKobo, + ref: paymentData.reference, + onSuccess: (response: any) => { + console.log('Payment successful:', response); + onSuccess?.(); + setIsLoading(false); + }, + onCancel: () => { + console.log('Payment cancelled'); + onClose?.(); + setIsLoading(false); + }, + }); + + paystack.openIframe(); + } catch (error) { + console.error('Payment initiation failed:', error); + setIsLoading(false); + } + }; + + const isButtonDisabled = disabled || isLoading || initiatePayment.isPending || !scriptLoaded; + + return ( + + ); +} + +export default PaystackPaymentButton; diff --git a/app/frontend/config/route-guard.config.ts b/app/frontend/config/route-guard.config.ts index 74709855..40653a5e 100644 --- a/app/frontend/config/route-guard.config.ts +++ b/app/frontend/config/route-guard.config.ts @@ -75,6 +75,12 @@ export const ROUTE_RULES: Record = { skeletonVariant: "generic", label: "My Tickets", }, + "/payments": { + requiredRole: "user", + redirectTo: "/login", + skeletonVariant: "generic", + label: "Payments", + }, // ── Organizer routes ───────────────────────────────────────────────────── "/events/create": { diff --git a/app/frontend/lib/apiClient.ts b/app/frontend/lib/apiClient.ts new file mode 100644 index 00000000..429e8f9e --- /dev/null +++ b/app/frontend/lib/apiClient.ts @@ -0,0 +1,46 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +apiClient.interceptors.request.use( + (config) => { + const token = getCookie('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + // Handle token refresh or redirect to login + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// Helper function to get cookies (client-side) +function getCookie(name: string): string | null { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return parts.pop()?.split(';').shift() || null; + } + return null; +} + +export default apiClient; diff --git a/app/frontend/lib/react-query/QueryClientProvider.tsx b/app/frontend/lib/react-query/QueryClientProvider.tsx new file mode 100644 index 00000000..106a306a --- /dev/null +++ b/app/frontend/lib/react-query/QueryClientProvider.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useState } from 'react'; + +interface ReactQueryProviderProps { + children: React.ReactNode; +} + +export function ReactQueryProvider({ children }: ReactQueryProviderProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: (failureCount, error) => { + // Don't retry on 4xx errors + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as any).status; + if (status >= 400 && status < 500) { + return false; + } + } + return failureCount < 3; + }, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + + {children} + + + ); +} diff --git a/app/frontend/lib/react-query/hooks/payments/useGetMyPayments.ts b/app/frontend/lib/react-query/hooks/payments/useGetMyPayments.ts new file mode 100644 index 00000000..de201dff --- /dev/null +++ b/app/frontend/lib/react-query/hooks/payments/useGetMyPayments.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../../../apiClient'; +import { queryKeys } from '../../keys/queryKeys'; + +interface Payment { + id: string; + reference: string; + bookingId: string; + amount: number; + status: 'SUCCESS' | 'PENDING' | 'FAILED'; + createdAt: string; + updatedAt: string; + booking: { + id: string; + workspace: { + id: string; + name: string; + }; + startDate: string; + endDate: string; + }; +} + +interface GetMyPaymentsParams { + page?: number; + limit?: number; + status?: 'SUCCESS' | 'PENDING' | 'FAILED'; +} + +interface GetMyPaymentsResponse { + data: Payment[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export const useGetMyPayments = (params: GetMyPaymentsParams = {}) => { + const { page = 1, limit = 10, status } = params; + + return useQuery({ + queryKey: queryKeys.paymentsList({ page, limit, status }), + queryFn: async () => { + const searchParams = new URLSearchParams(); + if (page) searchParams.set('page', page.toString()); + if (limit) searchParams.set('limit', limit.toString()); + if (status) searchParams.set('status', status); + + const { data } = await apiClient.get( + `/payments?${searchParams.toString()}` + ); + return data; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + }); +}; diff --git a/app/frontend/lib/react-query/hooks/payments/useInitiatePayment.ts b/app/frontend/lib/react-query/hooks/payments/useInitiatePayment.ts new file mode 100644 index 00000000..837e6fde --- /dev/null +++ b/app/frontend/lib/react-query/hooks/payments/useInitiatePayment.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../../apiClient'; +import { queryKeys } from '../../keys/queryKeys'; + +interface InitiatePaymentVariables { + bookingId: string; +} + +interface InitiatePaymentResponse { + authorizationUrl: string; + reference: string; +} + +export const useInitiatePayment = () => { + const queryClient = useQueryClient(); + + return useMutation< + InitiatePaymentResponse, + Error, + InitiatePaymentVariables + >({ + mutationFn: async ({ bookingId }: InitiatePaymentVariables) => { + const { data } = await apiClient.post( + '/payments/initiate', + { bookingId } + ); + return data; + }, + onSuccess: () => { + // Invalidate related queries to refresh data + queryClient.invalidateQueries({ queryKey: queryKeys.payments }); + queryClient.invalidateQueries({ queryKey: queryKeys.bookings }); + }, + onError: (error) => { + console.error('Failed to initiate payment:', error); + // You can add toast notifications here + }, + }); +}; diff --git a/app/frontend/lib/react-query/keys/queryKeys.ts b/app/frontend/lib/react-query/keys/queryKeys.ts new file mode 100644 index 00000000..546a9885 --- /dev/null +++ b/app/frontend/lib/react-query/keys/queryKeys.ts @@ -0,0 +1,30 @@ +export const queryKeys = { + // Auth + user: ['user'] as const, + + // Bookings + bookings: ['bookings'] as const, + booking: (id: string) => ['bookings', id] as const, + + // Payments + payments: ['payments'] as const, + paymentsList: (filters?: { page?: number; limit?: number; status?: string }) => + ['payments', 'list', filters] as const, + payment: (reference: string) => ['payments', reference] as const, + + // Workspaces + workspaces: ['workspaces'] as const, + workspace: (id: string) => ['workspaces', id] as const, + + // Invoices + invoices: ['invoices'] as const, + invoice: (id: string) => ['invoices', id] as const, + + // Analytics + analytics: ['analytics'] as const, + analyticsOverview: () => ['analytics', 'overview'] as const, + analyticsTransactions: () => ['analytics', 'transactions'] as const, + analyticsReports: () => ['analytics', 'reports'] as const, +} as const; + +export type QueryKey = typeof queryKeys[keyof typeof queryKeys]; diff --git a/app/frontend/package.json b/app/frontend/package.json index 401f1f4c..f1715cbf 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -28,7 +28,10 @@ "react-hook-form": "^7.71.1", "recharts": "^2.12.7", "uuid": "^11.0.5", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-devtools": "^5.56.2", + "axios": "^1.7.7" }, "devDependencies": { "@lhci/cli": "^0.14.0",