feat(billing): Add customer billing page with payment method management

- Add CustomerBilling page for customers to view payment history and manage cards
- Create AddPaymentMethodModal with Stripe Elements for secure card saving
- Support both Stripe Connect and direct API payment modes
- Auto-set first payment method as default when no default exists
- Add dark mode support for Stripe card input styling
- Add customer billing API endpoints for payment history and saved cards
- Add stripe_customer_id field to User model for Stripe customer tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-04 13:06:30 -05:00
parent 65faaae864
commit b0512a660c
17 changed files with 1725 additions and 54 deletions

View File

@@ -45,6 +45,7 @@ const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDash
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
@@ -537,7 +538,7 @@ const AppContent: React.FC = () => {
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/payments" element={<CustomerBilling />} />
<Route path="/support" element={<CustomerSupport />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />

View File

@@ -0,0 +1,446 @@
/**
* Add Payment Method Modal Component
*
* Uses Stripe Elements with SetupIntent to securely save card details
* without charging the customer.
*
* For Stripe Connect, we must initialize Stripe with the connected account ID
* so the SetupIntent (created on the connected account) can be confirmed.
*/
import React, { useState, useEffect } from 'react';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useCreateSetupIntent, useSetDefaultPaymentMethod, useCustomerPaymentMethods } from '../hooks/useCustomerBilling';
import { useQueryClient } from '@tanstack/react-query';
// Cache for Stripe instances per connected account
// Note: Module-level cache persists across component re-renders but not page reloads
const stripeInstanceCache: Record<string, Promise<Stripe | null>> = {};
// Clear cache entry (useful for debugging)
export const clearStripeCache = (key?: string) => {
if (key) {
delete stripeInstanceCache[key];
} else {
Object.keys(stripeInstanceCache).forEach(k => delete stripeInstanceCache[k]);
}
};
// Get or create Stripe instance for a connected account (or platform account if empty)
// For direct_api mode, customPublishableKey will be the tenant's key
// For connect mode, we use the platform's key with stripeAccount
const getStripeInstance = (
stripeAccount: string,
customPublishableKey?: string
): Promise<Stripe | null> => {
// Use custom key for direct_api mode, platform key for connect mode
const publishableKey = customPublishableKey || import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '';
// Use 'platform' as cache key for direct_api mode (empty stripeAccount)
// For direct_api with custom key, include key in cache to avoid conflicts
const cacheKey = customPublishableKey
? `direct_${customPublishableKey.substring(0, 20)}`
: (stripeAccount || 'platform');
console.log('[AddPaymentMethodModal] getStripeInstance called with:', {
stripeAccount: stripeAccount || '(empty - direct_api mode)',
cacheKey,
publishableKey: publishableKey.substring(0, 20) + '...',
isDirectApi: !!customPublishableKey,
});
if (!stripeInstanceCache[cacheKey]) {
console.log('[AddPaymentMethodModal] Creating new Stripe instance for:', cacheKey);
// Only pass stripeAccount option if it's not empty (connect mode)
// For direct_api mode, we use the tenant's own API keys (no connected account needed)
stripeInstanceCache[cacheKey] = stripeAccount
? loadStripe(publishableKey, { stripeAccount })
: loadStripe(publishableKey);
} else {
console.log('[AddPaymentMethodModal] Using cached Stripe instance for:', cacheKey);
}
return stripeInstanceCache[cacheKey];
};
interface CardFormProps {
clientSecret: string;
onSuccess: () => void;
onCancel: () => void;
}
const CardFormInner: React.FC<CardFormProps> = ({
clientSecret,
onSuccess,
onCancel,
}) => {
const { t } = useTranslation();
const stripe = useStripe();
const elements = useElements();
const queryClient = useQueryClient();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [cardComplete, setCardComplete] = useState(false);
// Get current payment methods to check if this is the first one
const { data: paymentMethodsData } = useCustomerPaymentMethods();
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
// Detect dark mode for Stripe CardElement styling
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
try {
// Confirm the SetupIntent with Stripe
const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardElement,
},
});
if (error) {
setErrorMessage(error.message || t('billing.addCardFailed', 'Failed to add card. Please try again.'));
setIsProcessing(false);
return;
}
if (setupIntent && setupIntent.status === 'succeeded') {
// Get the payment method ID from the setup intent
const paymentMethodId = typeof setupIntent.payment_method === 'string'
? setupIntent.payment_method
: setupIntent.payment_method?.id;
// Check if there's already a default payment method
const existingMethods = paymentMethodsData?.payment_methods;
const hasDefaultMethod = existingMethods?.some(pm => pm.is_default) ?? false;
console.log('[AddPaymentMethodModal] SetupIntent succeeded:', {
paymentMethodId,
existingMethodsCount: existingMethods?.length ?? 0,
hasDefaultMethod,
});
// Set as default if no default payment method exists yet
if (!hasDefaultMethod && paymentMethodId) {
console.log('[AddPaymentMethodModal] No default payment method exists, setting new one as default:', paymentMethodId);
// Set as default (fire and forget - don't block the success flow)
setDefaultPaymentMethod.mutate(paymentMethodId, {
onSuccess: () => {
console.log('[AddPaymentMethodModal] Successfully set payment method as default');
},
onError: (err) => {
console.error('[AddPaymentMethodModal] Failed to set default payment method:', err);
},
});
} else {
console.log('[AddPaymentMethodModal] Default already exists or no paymentMethodId - existingMethods:', existingMethods?.length, 'hasDefaultMethod:', hasDefaultMethod, 'paymentMethodId:', paymentMethodId);
}
// Invalidate payment methods to refresh the list
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
setIsComplete(true);
setTimeout(() => {
onSuccess();
}, 1500);
}
} catch (err: any) {
setErrorMessage(err.message || t('billing.unexpectedError', 'An unexpected error occurred.'));
setIsProcessing(false);
}
};
if (isComplete) {
return (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('billing.cardAdded', 'Card Added Successfully!')}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{t('billing.cardAddedDescription', 'Your payment method has been saved.')}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: isDarkMode ? '#f1f5f9' : '#1e293b',
iconColor: isDarkMode ? '#94a3b8' : '#64748b',
'::placeholder': {
color: isDarkMode ? '#64748b' : '#94a3b8',
},
},
invalid: {
color: isDarkMode ? '#f87171' : '#dc2626',
iconColor: isDarkMode ? '#f87171' : '#dc2626',
},
},
}}
onChange={(e) => setCardComplete(e.complete)}
/>
</div>
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={!stripe || isProcessing || !cardComplete}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving', 'Saving...')}
</>
) : (
<>
<CreditCard className="w-4 h-4" />
{t('billing.saveCard', 'Save Card')}
</>
)}
</button>
</div>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}
</p>
</form>
);
};
interface AddPaymentMethodModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const AddPaymentMethodModal: React.FC<AddPaymentMethodModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripeAccount, setStripeAccount] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
const [error, setError] = useState<string | null>(null);
const createSetupIntent = useCreateSetupIntent();
// Detect dark mode for Stripe Elements appearance
const [isDarkMode, setIsDarkMode] = useState(() =>
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Watch for dark mode changes
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (isOpen && !clientSecret && !createSetupIntent.isPending) {
// Create SetupIntent when modal opens
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
console.log('[AddPaymentMethodModal] SetupIntent response:', {
client_secret: data.client_secret?.substring(0, 30) + '...',
setup_intent_id: data.setup_intent_id,
customer_id: data.customer_id,
stripe_account: data.stripe_account,
publishable_key: data.publishable_key ? data.publishable_key.substring(0, 20) + '...' : null,
});
// stripe_account can be empty string for direct_api mode, or acct_xxx for connect mode
// Only undefined/null indicates an error
if (data.stripe_account === undefined || data.stripe_account === null) {
console.error('[AddPaymentMethodModal] stripe_account is undefined/null - payment system may not be configured correctly');
setError(t('billing.paymentSystemNotConfigured', 'The payment system is not fully configured. Please contact support.'));
return;
}
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
// Load Stripe - empty stripe_account means direct_api mode (use tenant's publishable_key)
// Non-empty stripe_account means connect mode (use platform key with connected account)
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
console.error('[AddPaymentMethodModal] SetupIntent error:', err);
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setClientSecret(null);
setStripeAccount(null);
setStripePromise(null);
setError(null);
}
}, [isOpen]);
const handleSuccess = () => {
onSuccess?.();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('billing.addPaymentMethod', 'Add Payment Method')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{createSetupIntent.isPending ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
<p className="text-gray-600 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div>
) : error ? (
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => {
setError(null);
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('common.tryAgain', 'Try Again')}
</button>
</div>
</div>
) : clientSecret && stripePromise ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: isDarkMode ? 'night' : 'stripe',
variables: {
colorPrimary: '#2563eb',
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
colorText: isDarkMode ? '#f1f5f9' : '#1e293b',
colorDanger: isDarkMode ? '#f87171' : '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '12px',
borderRadius: '8px',
},
},
}}
>
<CardFormInner
clientSecret={clientSecret}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</Elements>
) : null}
</div>
</div>
);
};
export default AddPaymentMethodModal;

View File

@@ -70,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
colorDanger: '#df1b41',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
spacingUnit: '4px',
spacingUnit: '12px',
borderRadius: '8px',
},
},

View File

@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
colorText: '#1e293b',
colorDanger: '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '4px',
spacingUnit: '12px',
borderRadius: '8px',
},
},

View File

@@ -88,14 +88,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
const handleQuickLogin = async (user: TestUser) => {
setLoading(user.email);
try {
// Call token auth API - username field contains email since we use email as username
const response = await apiClient.post('/auth-token/', {
username: user.email,
// Call custom login API that supports email login
const response = await apiClient.post('/auth/login/', {
email: user.email,
password: user.password,
});
// Store token in cookie (use 'access_token' to match what client.ts expects)
setCookie('access_token', response.data.token, 7);
setCookie('access_token', response.data.access, 7);
// Clear any existing masquerade stack - this is a fresh login
localStorage.removeItem('masquerade_stack');

View File

@@ -0,0 +1,155 @@
/**
* Customer Billing Hooks
*
* React Query hooks for fetching customer billing data including
* payment history, outstanding payments, and saved payment methods.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
// Types
export interface OutstandingPayment {
id: number;
title: string;
service_name: string;
amount: number;
amount_display: string;
status: string;
start_time: string | null;
end_time: string | null;
payment_status: 'pending' | 'unpaid';
payment_intent_id: string | null;
}
export interface PaymentHistoryItem {
id: number;
event_id: number;
event_title: string;
service_name: string;
amount: number;
amount_display: string;
currency: string;
status: string;
payment_intent_id: string;
created_at: string;
completed_at: string | null;
event_date: string | null;
}
export interface BillingSummary {
total_spent: number;
total_spent_display: string;
total_outstanding: number;
total_outstanding_display: string;
payment_count: number;
}
export interface CustomerBillingData {
outstanding: OutstandingPayment[];
payment_history: PaymentHistoryItem[];
summary: BillingSummary;
}
export interface SavedPaymentMethod {
id: string;
type: string;
brand: string | null;
last4: string | null;
exp_month: number | null;
exp_year: number | null;
is_default: boolean;
}
export interface CustomerPaymentMethodsData {
payment_methods: SavedPaymentMethod[];
has_stripe_customer: boolean;
message?: string;
}
/**
* Hook to fetch customer billing data (payment history + outstanding payments)
*/
export const useCustomerBilling = () => {
return useQuery<CustomerBillingData>({
queryKey: ['customerBilling'],
queryFn: async () => {
const { data } = await apiClient.get('/payments/customer/billing/');
return data;
},
staleTime: 30 * 1000, // 30 seconds
retry: false,
});
};
/**
* Hook to fetch customer's saved payment methods
*/
export const useCustomerPaymentMethods = () => {
return useQuery<CustomerPaymentMethodsData>({
queryKey: ['customerPaymentMethods'],
queryFn: async () => {
const { data } = await apiClient.get('/payments/customer/payment-methods/');
return data;
},
staleTime: 60 * 1000, // 1 minute
retry: false,
});
};
// SetupIntent response type
export interface SetupIntentResponse {
client_secret: string;
setup_intent_id: string;
customer_id: string;
stripe_account: string; // Connected account ID for Stripe Connect (empty for direct_api)
publishable_key?: string; // Tenant's publishable key for direct_api mode
}
/**
* Hook to create a SetupIntent for adding a new payment method
*/
export const useCreateSetupIntent = () => {
return useMutation<SetupIntentResponse>({
mutationFn: async () => {
const { data } = await apiClient.post('/payments/customer/setup-intent/');
return data;
},
});
};
/**
* Hook to delete a payment method
*/
export const useDeletePaymentMethod = () => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; message: string }, Error, string>({
mutationFn: async (paymentMethodId: string) => {
const { data } = await apiClient.delete(`/payments/customer/payment-methods/${paymentMethodId}/`);
return data;
},
onSuccess: () => {
// Invalidate payment methods query to refresh the list
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
},
});
};
/**
* Hook to set a payment method as default
*/
export const useSetDefaultPaymentMethod = () => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; message: string }, Error, string>({
mutationFn: async (paymentMethodId: string) => {
const { data } = await apiClient.post(`/payments/customer/payment-methods/${paymentMethodId}/default/`);
return data;
},
onSuccess: () => {
// Invalidate payment methods query to refresh the list
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
},
});
};

View File

@@ -29,8 +29,7 @@ import {
ExternalLink,
Eye,
} from 'lucide-react';
import { User, Business, PaymentMethod, Customer } from '../types';
import { CUSTOMERS } from '../mockData';
import { User, Business, PaymentMethod } from '../types';
import PaymentSettingsSection from '../components/PaymentSettingsSection';
import TransactionDetailModal from '../components/TransactionDetailModal';
import Portal from '../components/Portal';
@@ -96,43 +95,39 @@ const Payments: React.FC = () => {
const exportMutation = useExportTransactions();
// Customer view state (for customer-facing)
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
CUSTOMERS.find(c => c.userId === effectiveUser.id)
);
// Initialize with empty payment methods - real data will come from API when implemented
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
// Customer handlers
const handleSetDefault = (pmId: string) => {
if (!customerProfile) return;
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
const updatedMethods = paymentMethods.map(pm => ({
...pm,
isDefault: pm.id === pmId
}));
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
setPaymentMethods(updatedMethods);
};
const handleDeleteMethod = (pmId: string) => {
if (!customerProfile) return;
if (window.confirm(t('payments.confirmDeletePaymentMethod'))) {
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
const updatedMethods = paymentMethods.filter(pm => pm.id !== pmId);
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
updatedMethods[0].isDefault = true;
}
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
setPaymentMethods(updatedMethods);
}
};
const handleAddCard = (e: React.FormEvent) => {
e.preventDefault();
if (!customerProfile) return;
const newCard: PaymentMethod = {
id: `pm_${Date.now()}`,
brand: 'Visa',
last4: String(Math.floor(1000 + Math.random() * 9000)),
isDefault: customerProfile.paymentMethods.length === 0
isDefault: paymentMethods.length === 0
};
const updatedMethods = [...customerProfile.paymentMethods, newCard];
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
const updatedMethods = [...paymentMethods, newCard];
setPaymentMethods(updatedMethods);
setIsAddCardModalOpen(false);
};
@@ -788,7 +783,7 @@ const Payments: React.FC = () => {
}
// Customer View
if (isCustomer && customerProfile) {
if (isCustomer) {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
@@ -805,7 +800,7 @@ const Payments: React.FC = () => {
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{customerProfile.paymentMethods.length > 0 ? customerProfile.paymentMethods.map((pm) => (
{paymentMethods.length > 0 ? paymentMethods.map((pm) => (
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />

View File

@@ -1,13 +1,14 @@
import React, { useState } from 'react';
import { useOutletContext, Link } from 'react-router-dom';
import { User, Business, Service, Customer } from '../../types';
import { SERVICES, CUSTOMERS } from '../../mockData';
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard } from 'lucide-react';
import { User, Business, Service } from '../../types';
import { useServices } from '../../hooks/useServices';
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard, Loader2 } from 'lucide-react';
const BookingPage: React.FC = () => {
const { user, business } = useOutletContext<{ user: User, business: Business }>();
const customer = CUSTOMERS.find(c => c.userId === user.id);
// Fetch services from API - backend filters for current tenant
const { data: services = [], isLoading: servicesLoading } = useServices();
const [step, setStep] = useState(1);
const [selectedService, setSelectedService] = useState<Service | null>(null);
@@ -23,10 +24,6 @@ const BookingPage: React.FC = () => {
];
const handleSelectService = (service: Service) => {
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
// Handled by the conditional rendering below, but could also be an alert.
return;
}
setSelectedService(service);
setStep(2);
};
@@ -50,26 +47,25 @@ const BookingPage: React.FC = () => {
}
const renderStepContent = () => {
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
return (
<div className="text-center bg-yellow-50 dark:bg-yellow-900/30 p-8 rounded-lg border border-yellow-200 dark:border-yellow-700">
<AlertTriangle className="mx-auto text-yellow-500" size={40} />
<h3 className="mt-4 text-lg font-bold text-yellow-800 dark:text-yellow-200">Payment Method Required</h3>
<p className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
This business requires a payment method on file to book an appointment. Please add a card to your account before proceeding.
</p>
<Link to="/payments" className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 font-medium shadow-sm transition-colors">
<CreditCard size={16} /> Go to Billing
</Link>
</div>
)
}
switch (step) {
case 1: // Select Service
if (servicesLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
</div>
);
}
if (services.length === 0) {
return (
<div className="text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p className="text-gray-500 dark:text-gray-400">No services available for booking at this time.</p>
</div>
);
}
return (
<div className="space-y-4">
{SERVICES.map(service => (
{services.map(service => (
<button
key={service.id}
onClick={() => handleSelectService(service)}

View File

@@ -0,0 +1,418 @@
import React, { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CreditCard,
Receipt,
History,
AlertCircle,
Loader2,
CheckCircle,
Clock,
XCircle,
DollarSign,
Wallet,
RefreshCcw,
Plus,
Trash2,
Star,
} from 'lucide-react';
import { User, Business } from '../../types';
import {
useCustomerBilling,
useCustomerPaymentMethods,
useDeletePaymentMethod,
useSetDefaultPaymentMethod,
OutstandingPayment,
PaymentHistoryItem,
} from '../../hooks/useCustomerBilling';
import { AddPaymentMethodModal } from '../../components/AddPaymentMethodModal';
type TabType = 'outstanding' | 'history';
const CustomerBilling: React.FC = () => {
const { t } = useTranslation();
useOutletContext<{ user: User; business: Business }>(); // Validate context is available
const [activeTab, setActiveTab] = useState<TabType>('outstanding');
const [showAddPaymentModal, setShowAddPaymentModal] = useState(false);
const [deletingPaymentMethod, setDeletingPaymentMethod] = useState<string | null>(null);
// Fetch billing data from API
const { data: billingData, isLoading: billingLoading, error: billingError } = useCustomerBilling();
const { data: paymentMethodsData, isLoading: methodsLoading } = useCustomerPaymentMethods();
// Mutations for payment method management
const deletePaymentMethod = useDeletePaymentMethod();
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
const handleDeletePaymentMethod = async (paymentMethodId: string) => {
if (deletingPaymentMethod) return;
setDeletingPaymentMethod(paymentMethodId);
try {
await deletePaymentMethod.mutateAsync(paymentMethodId);
} finally {
setDeletingPaymentMethod(null);
}
};
const handleSetDefaultPaymentMethod = async (paymentMethodId: string) => {
await setDefaultPaymentMethod.mutateAsync(paymentMethodId);
};
const isLoading = billingLoading;
// Status badge helper for payments
const getPaymentStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100 dark:bg-green-900/50', text: 'text-green-800 dark:text-green-300', icon: <CheckCircle size={12} /> },
refunded: { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', icon: <RefreshCcw size={12} /> },
pending: { bg: 'bg-yellow-100 dark:bg-yellow-900/50', text: 'text-yellow-800 dark:text-yellow-300', icon: <Clock size={12} /> },
unpaid: { bg: 'bg-red-100 dark:bg-red-900/50', text: 'text-red-800 dark:text-red-300', icon: <AlertCircle size={12} /> },
failed: { bg: 'bg-red-100 dark:bg-red-900/50', text: 'text-red-800 dark:text-red-300', icon: <XCircle size={12} /> },
};
const style = styles[status] || styles.pending;
const displayStatus = status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${style.bg} ${style.text}`}>
{style.icon}
{displayStatus}
</span>
);
};
// Format date
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
// Format time
const formatTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Render outstanding payment card
const renderOutstandingCard = (item: OutstandingPayment) => {
return (
<div key={item.id} className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white">
{item.service_name || item.title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{formatDate(item.start_time)} {item.start_time && `at ${formatTime(item.start_time)}`}
</p>
</div>
<div className="flex flex-col items-end gap-2">
{getPaymentStatusBadge(item.payment_status)}
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{item.amount_display}
</span>
</div>
</div>
</div>
);
};
// Render payment history card
const renderHistoryCard = (item: PaymentHistoryItem) => {
return (
<div key={item.id} className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white">
{item.service_name || item.event_title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{formatDate(item.event_date)}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{t('billing.paidOn', 'Paid on')} {formatDate(item.completed_at || item.created_at)}
</p>
</div>
<div className="flex flex-col items-end gap-2">
{getPaymentStatusBadge(item.status)}
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{item.amount_display}
</span>
</div>
</div>
</div>
);
};
// Render card brand icon/text
const getCardBrandDisplay = (brand: string | null) => {
const brandMap: Record<string, string> = {
visa: 'Visa',
mastercard: 'Mastercard',
amex: 'American Express',
discover: 'Discover',
};
return brandMap[brand?.toLowerCase() || ''] || brand || 'Card';
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('billing.title', 'Billing & Payments')}
</h2>
<p className="text-gray-500 dark:text-gray-400">
{t('billing.description', 'View your payments, outstanding balances, and saved payment methods')}
</p>
</div>
{/* Summary Cards */}
{billingData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/50 rounded-lg">
<AlertCircle className="text-red-600 dark:text-red-400" size={20} />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.outstanding', 'Outstanding')}
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{billingData.summary.total_outstanding_display}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900/50 rounded-lg">
<DollarSign className="text-green-600 dark:text-green-400" size={20} />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.totalSpent', 'Total Spent')}
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{billingData.summary.total_spent_display}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<Receipt className="text-blue-600 dark:text-blue-400" size={20} />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.payments', 'Payments')}
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{billingData.summary.payment_count}
</p>
</div>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="flex gap-2 border-b border-gray-200 dark:border-gray-700">
{[
{ id: 'outstanding' as const, label: t('billing.outstandingTab', 'Outstanding'), icon: AlertCircle, count: billingData?.outstanding.length },
{ id: 'history' as const, label: t('billing.historyTab', 'Payment History'), icon: History, count: billingData?.payment_history.length },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<tab.icon size={16} />
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`ml-1 px-2 py-0.5 text-xs rounded-full ${
activeTab === tab.id
? 'bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
</div>
) : billingError ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
<AlertCircle className="mx-auto text-red-500 mb-3" size={40} />
<p className="text-red-700 dark:text-red-300">
{t('billing.errorLoading', 'Unable to load billing information. Please try again later.')}
</p>
</div>
) : activeTab === 'outstanding' ? (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
<Wallet size={20} className="text-brand-500" />
{t('billing.outstandingPayments', 'Outstanding Payments')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('billing.outstandingDescription', 'Appointments that require payment')}
</p>
</div>
{billingData && billingData.outstanding.length > 0 ? (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{billingData.outstanding.map(renderOutstandingCard)}
</div>
) : (
<div className="p-8 text-center">
<CheckCircle className="mx-auto text-green-500 mb-3" size={40} />
<p className="text-gray-500 dark:text-gray-400">
{t('billing.noOutstanding', 'No outstanding payments. You\'re all caught up!')}
</p>
</div>
)}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
<Receipt size={20} className="text-brand-500" />
{t('billing.paymentHistory', 'Payment History')}
</h3>
</div>
{billingData && billingData.payment_history.length > 0 ? (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{billingData.payment_history.map(renderHistoryCard)}
</div>
) : (
<div className="p-8 text-center">
<History className="mx-auto text-gray-400 mb-3" size={40} />
<p className="text-gray-500 dark:text-gray-400">
{t('billing.noPaymentHistory', 'No payment history yet')}
</p>
</div>
)}
</div>
)}
{/* Saved Payment Methods */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
<CreditCard size={20} className="text-brand-500" />
{t('billing.savedPaymentMethods', 'Saved Payment Methods')}
</h3>
</div>
<button
onClick={() => setShowAddPaymentModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Plus size={16} />
{t('billing.addCard', 'Add Card')}
</button>
</div>
{methodsLoading ? (
<div className="p-8 text-center">
<Loader2 className="mx-auto animate-spin text-gray-400 mb-3" size={24} />
</div>
) : paymentMethodsData && paymentMethodsData.payment_methods.length > 0 ? (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{paymentMethodsData.payment_methods.map((pm) => (
<div key={pm.id} className="p-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />
<div>
<p className="font-medium text-gray-900 dark:text-white">
{getCardBrandDisplay(pm.brand)} {t('billing.endingIn', 'ending in')} {pm.last4}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.expires', 'Expires')} {pm.exp_month}/{pm.exp_year}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{pm.is_default ? (
<span className="text-xs font-medium text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50 px-2 py-1 rounded">
{t('billing.default', 'Default')}
</span>
) : (
<button
onClick={() => handleSetDefaultPaymentMethod(pm.id)}
disabled={setDefaultPaymentMethod.isPending}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 rounded hover:border-brand-300 dark:hover:border-brand-600 transition-colors disabled:opacity-50"
title={t('billing.setAsDefault', 'Set as default')}
>
<Star size={12} />
{t('billing.setDefault', 'Set Default')}
</button>
)}
<button
onClick={() => handleDeletePaymentMethod(pm.id)}
disabled={deletingPaymentMethod === pm.id}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded hover:border-red-300 dark:hover:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
title={t('billing.removeCard', 'Remove card')}
>
{deletingPaymentMethod === pm.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Trash2 size={12} />
)}
{t('common.remove', 'Remove')}
</button>
</div>
</div>
))}
</div>
) : (
<div className="p-8 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
<CreditCard className="text-gray-400" size={24} />
</div>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{paymentMethodsData?.message || t('billing.noSavedMethods', 'No saved payment methods')}
</p>
<button
onClick={() => setShowAddPaymentModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Plus size={16} />
{t('billing.addPaymentMethod', 'Add Payment Method')}
</button>
</div>
)}
</div>
{/* Add Payment Method Modal */}
<AddPaymentMethodModal
isOpen={showAddPaymentModal}
onClose={() => setShowAddPaymentModal(false)}
/>
</div>
);
};
export default CustomerBilling;