Features: - Complete multi-step booking flow with service selection, date/time picker, auth (login/signup with email verification), payment, and confirmation - Business hours settings page for defining when business is open - TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE) - Service resource assignment with prep/takedown time buffers - Availability checking respects business hours and service buffers - Customer registration via email verification code UI/UX: - Full dark mode support for all booking components - Separate first/last name fields in signup form - Back buttons on each wizard step - Removed auto-redirect from confirmation page API: - Public endpoints for services, availability, business hours - Customer verification and registration endpoints - Tenant lookup from X-Business-Subdomain header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
160 lines
8.8 KiB
TypeScript
160 lines
8.8 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { PublicService } from '../../hooks/useBooking';
|
|
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
|
|
|
|
interface PaymentSectionProps {
|
|
service: PublicService;
|
|
onPaymentComplete: () => void;
|
|
}
|
|
|
|
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
|
|
const [processing, setProcessing] = useState(false);
|
|
const [cardNumber, setCardNumber] = useState('');
|
|
const [expiry, setExpiry] = useState('');
|
|
const [cvc, setCvc] = useState('');
|
|
|
|
// Convert cents to dollars
|
|
const price = service.price_cents / 100;
|
|
const deposit = (service.deposit_amount_cents || 0) / 100;
|
|
|
|
// Auto-format card number
|
|
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
let val = e.target.value.replace(/\D/g, '');
|
|
val = val.substring(0, 16);
|
|
val = val.replace(/(\d{4})/g, '$1 ').trim();
|
|
setCardNumber(val);
|
|
};
|
|
|
|
const handlePayment = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setProcessing(true);
|
|
|
|
// Simulate Stripe Payment Intent & Processing
|
|
setTimeout(() => {
|
|
setProcessing(false);
|
|
onPaymentComplete();
|
|
}, 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Payment Details Column */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
|
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
|
Card Details
|
|
</h3>
|
|
<div className="flex space-x-2">
|
|
{/* Mock Card Icons */}
|
|
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
|
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
|
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={cardNumber}
|
|
onChange={handleCardInput}
|
|
placeholder="0000 0000 0000 0000"
|
|
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={expiry}
|
|
onChange={(e) => setExpiry(e.target.value)}
|
|
placeholder="MM / YY"
|
|
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
required
|
|
value={cvc}
|
|
onChange={(e) => setCvc(e.target.value)}
|
|
placeholder="123"
|
|
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
|
/>
|
|
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
|
|
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
|
|
<p className="text-sm text-indigo-800 dark:text-indigo-200">
|
|
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Column */}
|
|
<div className="lg:col-span-1">
|
|
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
|
|
<div className="space-y-3 text-sm">
|
|
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
|
<span>Service Total</span>
|
|
<span>${price.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
|
<span>Tax (Estimated)</span>
|
|
<span>$0.00</span>
|
|
</div>
|
|
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
|
|
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
|
|
<span>Total</span>
|
|
<span>${price.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{deposit > 0 ? (
|
|
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
|
|
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Due at appointment</span>
|
|
<span>${(price - deposit).toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
|
|
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
form="payment-form"
|
|
disabled={processing}
|
|
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
|
|
>
|
|
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|