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

@@ -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;