@bailierich/booking-components 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/TENANT_DATA_INTEGRATION.md +402 -0
- package/TENANT_SETUP.md +316 -0
- package/components/BookingFlow/BookingFlow.tsx +790 -0
- package/components/BookingFlow/index.ts +5 -0
- package/components/BookingFlow/steps/AddonsSelection.tsx +118 -0
- package/components/BookingFlow/steps/Confirmation.tsx +185 -0
- package/components/BookingFlow/steps/ContactForm.tsx +292 -0
- package/components/BookingFlow/steps/CycleAwareDateSelection.tsx +277 -0
- package/components/BookingFlow/steps/DateSelection.tsx +473 -0
- package/components/BookingFlow/steps/ServiceSelection.tsx +315 -0
- package/components/BookingFlow/steps/TimeSelection.tsx +230 -0
- package/components/BookingFlow/steps/index.ts +10 -0
- package/components/BottomSheet/index.tsx +120 -0
- package/components/Forms/FormBlock.tsx +283 -0
- package/components/Forms/FormField.tsx +385 -0
- package/components/Forms/FormRenderer.tsx +216 -0
- package/components/Forms/FormValidation.ts +122 -0
- package/components/Forms/index.ts +4 -0
- package/components/HoldTimer/HoldTimer.tsx +266 -0
- package/components/HoldTimer/index.ts +2 -0
- package/components/SectionRenderer.tsx +558 -0
- package/components/Sections/About.tsx +145 -0
- package/components/Sections/BeforeAfter.tsx +81 -0
- package/components/Sections/BookingSection.tsx +76 -0
- package/components/Sections/Contact.tsx +103 -0
- package/components/Sections/FAQSection.tsx +239 -0
- package/components/Sections/FeatureContent.tsx +113 -0
- package/components/Sections/FeaturedLink.tsx +103 -0
- package/components/Sections/FixedInfoCard.tsx +189 -0
- package/components/Sections/Gallery.tsx +83 -0
- package/components/Sections/Header.tsx +78 -0
- package/components/Sections/Hero.tsx +178 -0
- package/components/Sections/ImageSection.tsx +147 -0
- package/components/Sections/InstagramFeed.tsx +38 -0
- package/components/Sections/LinkList.tsx +76 -0
- package/components/Sections/LocationMap.tsx +202 -0
- package/components/Sections/Logo.tsx +61 -0
- package/components/Sections/MinimalFooter.tsx +78 -0
- package/components/Sections/MinimalHeader.tsx +81 -0
- package/components/Sections/MinimalNavigation.tsx +63 -0
- package/components/Sections/Navbar.tsx +258 -0
- package/components/Sections/PricingTable.tsx +106 -0
- package/components/Sections/ScrollingTextDivider.tsx +138 -0
- package/components/Sections/ScrollingTextDivider.tsx.bak +138 -0
- package/components/Sections/ServicesPreview.tsx +129 -0
- package/components/Sections/SocialBar.tsx +177 -0
- package/components/Sections/Team.tsx +80 -0
- package/components/Sections/Testimonials.tsx +92 -0
- package/components/Sections/TextSection.tsx +116 -0
- package/components/Sections/VideoSection.tsx +178 -0
- package/components/Sections/index.ts +57 -0
- package/components/index.ts +21 -0
- package/dist/index-DAai7Glf.d.mts +474 -0
- package/dist/index-DAai7Glf.d.ts +474 -0
- package/dist/index.d.mts +1075 -0
- package/dist/index.d.ts +1075 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +22 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles/index.d.mts +1 -0
- package/dist/styles/index.d.ts +1 -0
- package/dist/styles/index.js +2 -0
- package/dist/styles/index.js.map +1 -0
- package/dist/styles/index.mjs +2 -0
- package/dist/styles/index.mjs.map +1 -0
- package/docs/API.md +849 -0
- package/docs/CALLBACKS.md +760 -0
- package/docs/COMPLETE_SESSION_SUMMARY.md +404 -0
- package/docs/DATA_SHAPES.md +684 -0
- package/docs/MIGRATION.md +662 -0
- package/docs/PAYMENT_INTEGRATION.md +766 -0
- package/docs/SESSION_SUMMARY.md +185 -0
- package/docs/STYLING.md +735 -0
- package/index.ts +4 -0
- package/lib/storage.ts +239 -0
- package/package.json +59 -0
- package/styles/animations.ts +210 -0
- package/styles/index.ts +1 -0
- package/tsconfig.json +32 -0
- package/tsup.config.ts +13 -0
- package/types/index.ts +369 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
# Payment Integration Guide
|
|
2
|
+
|
|
3
|
+
Complete guide for integrating payment providers with the booking components.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Overview](#overview)
|
|
8
|
+
- [Square Integration](#square-integration)
|
|
9
|
+
- [Stripe Integration](#stripe-integration)
|
|
10
|
+
- [Deposit vs Full Payment](#deposit-vs-full-payment)
|
|
11
|
+
- [Handling Payment Errors](#handling-payment-errors)
|
|
12
|
+
- [Security Best Practices](#security-best-practices)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
The Confirmation component supports payment collection during the booking process. You can integrate Square, Stripe, or any other payment provider.
|
|
19
|
+
|
|
20
|
+
### Payment Flow
|
|
21
|
+
|
|
22
|
+
1. User completes booking details (service, date, time, contact info)
|
|
23
|
+
2. User reaches confirmation step
|
|
24
|
+
3. Payment form is presented (deposit or full payment)
|
|
25
|
+
4. Payment is processed
|
|
26
|
+
5. Booking is confirmed and saved
|
|
27
|
+
6. Confirmation email is sent
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Square Integration
|
|
32
|
+
|
|
33
|
+
### 1. Install Square SDK
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install square
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Set Up Square Web Payments SDK
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// lib/square.ts
|
|
43
|
+
export async function initializeSquarePayments() {
|
|
44
|
+
if (!window.Square) {
|
|
45
|
+
throw new Error('Square.js failed to load');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const payments = window.Square.payments(
|
|
49
|
+
process.env.NEXT_PUBLIC_SQUARE_APPLICATION_ID!,
|
|
50
|
+
process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID!
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return payments;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function createSquareCard(payments: any) {
|
|
57
|
+
const card = await payments.card();
|
|
58
|
+
await card.attach('#square-card-container');
|
|
59
|
+
return card;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 3. Create Payment Component
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// components/SquarePayment.tsx
|
|
67
|
+
'use client';
|
|
68
|
+
|
|
69
|
+
import { useEffect, useState } from 'react';
|
|
70
|
+
import { initializeSquarePayments, createSquareCard } from '@/lib/square';
|
|
71
|
+
|
|
72
|
+
interface SquarePaymentProps {
|
|
73
|
+
amount: number;
|
|
74
|
+
onPaymentSuccess: (paymentId: string) => void;
|
|
75
|
+
onPaymentError: (error: Error) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function SquarePayment({
|
|
79
|
+
amount,
|
|
80
|
+
onPaymentSuccess,
|
|
81
|
+
onPaymentError
|
|
82
|
+
}: SquarePaymentProps) {
|
|
83
|
+
const [card, setCard] = useState<any>(null);
|
|
84
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
async function setupSquare() {
|
|
88
|
+
try {
|
|
89
|
+
const payments = await initializeSquarePayments();
|
|
90
|
+
const squareCard = await createSquareCard(payments);
|
|
91
|
+
setCard(squareCard);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Failed to initialize Square:', error);
|
|
94
|
+
onPaymentError(new Error('Payment system initialization failed'));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setupSquare();
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
if (card) {
|
|
102
|
+
card.destroy();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const handlePayment = async () => {
|
|
108
|
+
if (!card || isProcessing) return;
|
|
109
|
+
|
|
110
|
+
setIsProcessing(true);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Tokenize card
|
|
114
|
+
const result = await card.tokenize();
|
|
115
|
+
|
|
116
|
+
if (result.status === 'OK') {
|
|
117
|
+
// Send token to backend
|
|
118
|
+
const response = await fetch('/api/payments/square', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
sourceId: result.token,
|
|
123
|
+
amount: amount * 100, // Convert to cents
|
|
124
|
+
currency: 'USD'
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error('Payment processing failed');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = await response.json();
|
|
133
|
+
onPaymentSuccess(data.paymentId);
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error(result.errors[0]?.message || 'Tokenization failed');
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
onPaymentError(error as Error);
|
|
139
|
+
} finally {
|
|
140
|
+
setIsProcessing(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="space-y-4">
|
|
146
|
+
<div id="square-card-container" className="min-h-[200px]" />
|
|
147
|
+
|
|
148
|
+
<button
|
|
149
|
+
onClick={handlePayment}
|
|
150
|
+
disabled={!card || isProcessing}
|
|
151
|
+
className="w-full py-3 px-6 rounded-lg text-white font-medium disabled:opacity-50"
|
|
152
|
+
style={{ backgroundColor: '#006AFF' }}
|
|
153
|
+
>
|
|
154
|
+
{isProcessing ? 'Processing...' : `Pay $${amount.toFixed(2)}`}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 4. Backend API Route
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
// app/api/payments/square/route.ts
|
|
165
|
+
import { Client, Environment } from 'square';
|
|
166
|
+
|
|
167
|
+
const client = new Client({
|
|
168
|
+
accessToken: process.env.SQUARE_ACCESS_TOKEN!,
|
|
169
|
+
environment: Environment.Production
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export async function POST(request: Request) {
|
|
173
|
+
try {
|
|
174
|
+
const { sourceId, amount, currency } = await request.json();
|
|
175
|
+
|
|
176
|
+
const { result } = await client.paymentsApi.createPayment({
|
|
177
|
+
sourceId,
|
|
178
|
+
amountMoney: {
|
|
179
|
+
amount: BigInt(amount),
|
|
180
|
+
currency
|
|
181
|
+
},
|
|
182
|
+
locationId: process.env.SQUARE_LOCATION_ID!,
|
|
183
|
+
idempotencyKey: crypto.randomUUID()
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return Response.json({
|
|
187
|
+
paymentId: result.payment?.id,
|
|
188
|
+
status: result.payment?.status
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error('Square payment error:', error);
|
|
192
|
+
return Response.json(
|
|
193
|
+
{ error: 'Payment failed' },
|
|
194
|
+
{ status: 500 }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 5. Use with Confirmation Component
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { Confirmation } from '@oviah/booking-components';
|
|
204
|
+
import { SquarePayment } from '@/components/SquarePayment';
|
|
205
|
+
|
|
206
|
+
<Confirmation
|
|
207
|
+
service={selectedService}
|
|
208
|
+
addons={selectedAddons}
|
|
209
|
+
selectedDate={selectedDate}
|
|
210
|
+
selectedTime={selectedTime}
|
|
211
|
+
contactInfo={contactInfo}
|
|
212
|
+
paymentProvider="square"
|
|
213
|
+
paymentConfig={{
|
|
214
|
+
depositPercentage: 20
|
|
215
|
+
}}
|
|
216
|
+
onConfirm={async () => {
|
|
217
|
+
// Payment handled by SquarePayment component
|
|
218
|
+
}}
|
|
219
|
+
colors={colors}
|
|
220
|
+
/>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Stripe Integration
|
|
226
|
+
|
|
227
|
+
### 1. Install Stripe SDK
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npm install @stripe/stripe-js @stripe/react-stripe-js
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 2. Set Up Stripe Elements
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
// lib/stripe.ts
|
|
237
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
238
|
+
|
|
239
|
+
export const stripePromise = loadStripe(
|
|
240
|
+
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
|
|
241
|
+
);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 3. Create Payment Component
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
// components/StripePayment.tsx
|
|
248
|
+
'use client';
|
|
249
|
+
|
|
250
|
+
import { useState } from 'react';
|
|
251
|
+
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
|
252
|
+
import { stripePromise } from '@/lib/stripe';
|
|
253
|
+
|
|
254
|
+
interface StripePaymentFormProps {
|
|
255
|
+
amount: number;
|
|
256
|
+
onPaymentSuccess: (paymentIntentId: string) => void;
|
|
257
|
+
onPaymentError: (error: Error) => void;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function StripePaymentForm({
|
|
261
|
+
amount,
|
|
262
|
+
onPaymentSuccess,
|
|
263
|
+
onPaymentError
|
|
264
|
+
}: StripePaymentFormProps) {
|
|
265
|
+
const stripe = useStripe();
|
|
266
|
+
const elements = useElements();
|
|
267
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
268
|
+
|
|
269
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
|
|
272
|
+
if (!stripe || !elements) return;
|
|
273
|
+
|
|
274
|
+
setIsProcessing(true);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Create payment intent on backend
|
|
278
|
+
const response = await fetch('/api/payments/stripe/create-intent', {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({ amount: amount * 100 }) // Convert to cents
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const { clientSecret } = await response.json();
|
|
285
|
+
|
|
286
|
+
// Confirm payment
|
|
287
|
+
const cardElement = elements.getElement(CardElement);
|
|
288
|
+
if (!cardElement) throw new Error('Card element not found');
|
|
289
|
+
|
|
290
|
+
const result = await stripe.confirmCardPayment(clientSecret, {
|
|
291
|
+
payment_method: {
|
|
292
|
+
card: cardElement
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (result.error) {
|
|
297
|
+
throw new Error(result.error.message);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onPaymentSuccess(result.paymentIntent.id);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
onPaymentError(error as Error);
|
|
303
|
+
} finally {
|
|
304
|
+
setIsProcessing(false);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
310
|
+
<div className="p-4 border border-gray-200 rounded-lg">
|
|
311
|
+
<CardElement
|
|
312
|
+
options={{
|
|
313
|
+
style: {
|
|
314
|
+
base: {
|
|
315
|
+
fontSize: '16px',
|
|
316
|
+
color: '#424770',
|
|
317
|
+
'::placeholder': {
|
|
318
|
+
color: '#aab7c4'
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
invalid: {
|
|
322
|
+
color: '#9e2146'
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}}
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<button
|
|
330
|
+
type="submit"
|
|
331
|
+
disabled={!stripe || isProcessing}
|
|
332
|
+
className="w-full py-3 px-6 rounded-lg text-white font-medium disabled:opacity-50"
|
|
333
|
+
style={{ backgroundColor: '#635BFF' }}
|
|
334
|
+
>
|
|
335
|
+
{isProcessing ? 'Processing...' : `Pay $${amount.toFixed(2)}`}
|
|
336
|
+
</button>
|
|
337
|
+
</form>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function StripePayment(props: StripePaymentFormProps) {
|
|
342
|
+
return (
|
|
343
|
+
<Elements stripe={stripePromise}>
|
|
344
|
+
<StripePaymentForm {...props} />
|
|
345
|
+
</Elements>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### 4. Backend API Routes
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
// app/api/payments/stripe/create-intent/route.ts
|
|
354
|
+
import Stripe from 'stripe';
|
|
355
|
+
|
|
356
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
357
|
+
apiVersion: '2023-10-16'
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
export async function POST(request: Request) {
|
|
361
|
+
try {
|
|
362
|
+
const { amount } = await request.json();
|
|
363
|
+
|
|
364
|
+
const paymentIntent = await stripe.paymentIntents.create({
|
|
365
|
+
amount,
|
|
366
|
+
currency: 'usd',
|
|
367
|
+
automatic_payment_methods: {
|
|
368
|
+
enabled: true
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return Response.json({
|
|
373
|
+
clientSecret: paymentIntent.client_secret
|
|
374
|
+
});
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.error('Stripe payment intent error:', error);
|
|
377
|
+
return Response.json(
|
|
378
|
+
{ error: 'Failed to create payment intent' },
|
|
379
|
+
{ status: 500 }
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Deposit vs Full Payment
|
|
388
|
+
|
|
389
|
+
### Configuration
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
const paymentConfig = {
|
|
393
|
+
depositPercentage: 20, // 20% deposit
|
|
394
|
+
requireDeposit: true // or false for full payment
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
<Confirmation
|
|
398
|
+
paymentConfig={paymentConfig}
|
|
399
|
+
// ... other props
|
|
400
|
+
/>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Calculating Amounts
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
function calculatePaymentAmounts(
|
|
407
|
+
servicePrice: number,
|
|
408
|
+
addonsPrice: number,
|
|
409
|
+
depositPercentage: number
|
|
410
|
+
) {
|
|
411
|
+
const total = servicePrice + addonsPrice;
|
|
412
|
+
const deposit = depositPercentage > 0
|
|
413
|
+
? (total * depositPercentage) / 100
|
|
414
|
+
: total;
|
|
415
|
+
const remaining = total - deposit;
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
total: total.toFixed(2),
|
|
419
|
+
deposit: deposit.toFixed(2),
|
|
420
|
+
remaining: remaining.toFixed(2)
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Usage
|
|
425
|
+
const amounts = calculatePaymentAmounts(100, 25, 20);
|
|
426
|
+
// {
|
|
427
|
+
// total: "125.00",
|
|
428
|
+
// deposit: "25.00",
|
|
429
|
+
// remaining: "100.00"
|
|
430
|
+
// }
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Handling Remaining Balance
|
|
434
|
+
|
|
435
|
+
```tsx
|
|
436
|
+
// Store deposit payment info
|
|
437
|
+
await prisma.booking.create({
|
|
438
|
+
data: {
|
|
439
|
+
serviceId: selectedService,
|
|
440
|
+
date: selectedDate,
|
|
441
|
+
time: selectedTime,
|
|
442
|
+
totalAmount: amounts.total,
|
|
443
|
+
depositAmount: amounts.deposit,
|
|
444
|
+
depositPaid: true,
|
|
445
|
+
depositPaymentId: paymentId,
|
|
446
|
+
remainingBalance: amounts.remaining,
|
|
447
|
+
balanceDueDate: appointmentDate // Due at appointment
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Send confirmation with balance due info
|
|
452
|
+
await sendConfirmationEmail({
|
|
453
|
+
to: contactInfo.email,
|
|
454
|
+
booking: bookingData,
|
|
455
|
+
depositAmount: amounts.deposit,
|
|
456
|
+
remainingBalance: amounts.remaining,
|
|
457
|
+
balanceDueText: `$${amounts.remaining} due at your appointment`
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Handling Payment Errors
|
|
464
|
+
|
|
465
|
+
### Error Types
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
enum PaymentErrorType {
|
|
469
|
+
CARD_DECLINED = 'card_declined',
|
|
470
|
+
INSUFFICIENT_FUNDS = 'insufficient_funds',
|
|
471
|
+
INVALID_CARD = 'invalid_card',
|
|
472
|
+
PROCESSING_ERROR = 'processing_error',
|
|
473
|
+
NETWORK_ERROR = 'network_error'
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
interface PaymentError {
|
|
477
|
+
type: PaymentErrorType;
|
|
478
|
+
message: string;
|
|
479
|
+
code?: string;
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Error Handling Implementation
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
function handlePaymentError(error: Error) {
|
|
487
|
+
let userMessage = 'Payment failed. Please try again.';
|
|
488
|
+
|
|
489
|
+
// Parse error message
|
|
490
|
+
if (error.message.includes('declined')) {
|
|
491
|
+
userMessage = 'Your card was declined. Please use a different payment method.';
|
|
492
|
+
} else if (error.message.includes('insufficient')) {
|
|
493
|
+
userMessage = 'Insufficient funds. Please use a different card.';
|
|
494
|
+
} else if (error.message.includes('invalid')) {
|
|
495
|
+
userMessage = 'Invalid card information. Please check and try again.';
|
|
496
|
+
} else if (error.message.includes('network')) {
|
|
497
|
+
userMessage = 'Network error. Please check your connection and try again.';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Log error for debugging
|
|
501
|
+
console.error('Payment error:', error);
|
|
502
|
+
|
|
503
|
+
// Track error analytics
|
|
504
|
+
analytics.track('Payment Error', {
|
|
505
|
+
error: error.message,
|
|
506
|
+
timestamp: new Date().toISOString()
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Show user-friendly message
|
|
510
|
+
toast.error(userMessage);
|
|
511
|
+
|
|
512
|
+
// Optional: Send to error tracking service
|
|
513
|
+
if (typeof window !== 'undefined' && window.Sentry) {
|
|
514
|
+
window.Sentry.captureException(error, {
|
|
515
|
+
tags: { context: 'payment' }
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Retry Logic
|
|
522
|
+
|
|
523
|
+
```tsx
|
|
524
|
+
async function processPaymentWithRetry(
|
|
525
|
+
paymentFn: () => Promise<string>,
|
|
526
|
+
maxRetries = 3
|
|
527
|
+
): Promise<string> {
|
|
528
|
+
let lastError: Error | null = null;
|
|
529
|
+
|
|
530
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
531
|
+
try {
|
|
532
|
+
return await paymentFn();
|
|
533
|
+
} catch (error) {
|
|
534
|
+
lastError = error as Error;
|
|
535
|
+
|
|
536
|
+
// Don't retry for card declined or invalid card
|
|
537
|
+
if (
|
|
538
|
+
error.message.includes('declined') ||
|
|
539
|
+
error.message.includes('invalid')
|
|
540
|
+
) {
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Wait before retrying (exponential backoff)
|
|
545
|
+
if (attempt < maxRetries) {
|
|
546
|
+
await new Promise(resolve =>
|
|
547
|
+
setTimeout(resolve, Math.pow(2, attempt) * 1000)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
throw lastError || new Error('Payment failed after retries');
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## Security Best Practices
|
|
560
|
+
|
|
561
|
+
### 1. Never Store Card Details
|
|
562
|
+
|
|
563
|
+
```tsx
|
|
564
|
+
// ❌ BAD - Never do this
|
|
565
|
+
const cardInfo = {
|
|
566
|
+
number: '4242424242424242',
|
|
567
|
+
cvv: '123',
|
|
568
|
+
expiry: '12/25'
|
|
569
|
+
};
|
|
570
|
+
localStorage.setItem('card', JSON.stringify(cardInfo));
|
|
571
|
+
|
|
572
|
+
// ✅ GOOD - Use payment provider tokens
|
|
573
|
+
const { token } = await stripe.createToken(cardElement);
|
|
574
|
+
// Only store/send the token, never raw card data
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### 2. Use HTTPS Only
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
// next.config.js
|
|
581
|
+
module.exports = {
|
|
582
|
+
async headers() {
|
|
583
|
+
return [
|
|
584
|
+
{
|
|
585
|
+
source: '/:path*',
|
|
586
|
+
headers: [
|
|
587
|
+
{
|
|
588
|
+
key: 'Strict-Transport-Security',
|
|
589
|
+
value: 'max-age=31536000; includeSubDomains'
|
|
590
|
+
}
|
|
591
|
+
]
|
|
592
|
+
}
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 3. Validate on Backend
|
|
599
|
+
|
|
600
|
+
```tsx
|
|
601
|
+
// app/api/payments/route.ts
|
|
602
|
+
export async function POST(request: Request) {
|
|
603
|
+
const { amount, sourceId } = await request.json();
|
|
604
|
+
|
|
605
|
+
// Validate amount
|
|
606
|
+
if (!amount || amount < 50) {
|
|
607
|
+
return Response.json(
|
|
608
|
+
{ error: 'Invalid amount' },
|
|
609
|
+
{ status: 400 }
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Validate source
|
|
614
|
+
if (!sourceId || typeof sourceId !== 'string') {
|
|
615
|
+
return Response.json(
|
|
616
|
+
{ error: 'Invalid payment source' },
|
|
617
|
+
{ status: 400 }
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Process payment...
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### 4. Use Idempotency Keys
|
|
626
|
+
|
|
627
|
+
```tsx
|
|
628
|
+
// Prevent duplicate charges
|
|
629
|
+
const idempotencyKey = `booking-${bookingId}-${Date.now()}`;
|
|
630
|
+
|
|
631
|
+
await stripe.paymentIntents.create({
|
|
632
|
+
amount,
|
|
633
|
+
currency: 'usd',
|
|
634
|
+
// ... other params
|
|
635
|
+
}, {
|
|
636
|
+
idempotencyKey
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### 5. Implement Rate Limiting
|
|
641
|
+
|
|
642
|
+
```tsx
|
|
643
|
+
// middleware.ts
|
|
644
|
+
import { Ratelimit } from '@upstash/ratelimit';
|
|
645
|
+
import { Redis } from '@upstash/redis';
|
|
646
|
+
|
|
647
|
+
const ratelimit = new Ratelimit({
|
|
648
|
+
redis: Redis.fromEnv(),
|
|
649
|
+
limiter: Ratelimit.slidingWindow(5, '1 m') // 5 requests per minute
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
export async function middleware(request: Request) {
|
|
653
|
+
if (request.url.includes('/api/payments')) {
|
|
654
|
+
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
|
655
|
+
const { success } = await ratelimit.limit(ip);
|
|
656
|
+
|
|
657
|
+
if (!success) {
|
|
658
|
+
return new Response('Too many requests', { status: 429 });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return NextResponse.next();
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Complete Integration Example
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
// app/booking/page.tsx
|
|
672
|
+
'use client';
|
|
673
|
+
|
|
674
|
+
import { useState } from 'react';
|
|
675
|
+
import { BookingFlow } from '@oviah/booking-components';
|
|
676
|
+
import { SquarePayment } from '@/components/SquarePayment';
|
|
677
|
+
import { toast } from 'sonner';
|
|
678
|
+
|
|
679
|
+
export default function BookingPage() {
|
|
680
|
+
const [bookingData, setBookingData] = useState(null);
|
|
681
|
+
|
|
682
|
+
const handleBookingComplete = async (data: any) => {
|
|
683
|
+
try {
|
|
684
|
+
// 1. Create booking (pending payment)
|
|
685
|
+
const bookingResponse = await fetch('/api/bookings', {
|
|
686
|
+
method: 'POST',
|
|
687
|
+
headers: { 'Content-Type': 'application/json' },
|
|
688
|
+
body: JSON.stringify({
|
|
689
|
+
...data,
|
|
690
|
+
status: 'pending_payment'
|
|
691
|
+
})
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const booking = await bookingResponse.json();
|
|
695
|
+
setBookingData(booking);
|
|
696
|
+
|
|
697
|
+
// 2. Show payment UI
|
|
698
|
+
// Payment handled by embedded component
|
|
699
|
+
|
|
700
|
+
} catch (error) {
|
|
701
|
+
toast.error('Failed to create booking');
|
|
702
|
+
console.error(error);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const handlePaymentSuccess = async (paymentId: string) => {
|
|
707
|
+
try {
|
|
708
|
+
// Update booking with payment info
|
|
709
|
+
await fetch(`/api/bookings/${bookingData.id}`, {
|
|
710
|
+
method: 'PATCH',
|
|
711
|
+
headers: { 'Content-Type': 'application/json' },
|
|
712
|
+
body: JSON.stringify({
|
|
713
|
+
status: 'confirmed',
|
|
714
|
+
paymentId
|
|
715
|
+
})
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// Send confirmation email
|
|
719
|
+
await fetch('/api/emails/confirmation', {
|
|
720
|
+
method: 'POST',
|
|
721
|
+
headers: { 'Content-Type': 'application/json' },
|
|
722
|
+
body: JSON.stringify({ bookingId: bookingData.id })
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Redirect to success page
|
|
726
|
+
window.location.href = `/booking/success?id=${bookingData.id}`;
|
|
727
|
+
|
|
728
|
+
} catch (error) {
|
|
729
|
+
toast.error('Failed to confirm booking');
|
|
730
|
+
console.error(error);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const handlePaymentError = (error: Error) => {
|
|
735
|
+
toast.error('Payment failed. Please try again.');
|
|
736
|
+
console.error(error);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
return (
|
|
740
|
+
<div className="container mx-auto px-4 py-8">
|
|
741
|
+
<BookingFlow
|
|
742
|
+
config={config}
|
|
743
|
+
colors={colors}
|
|
744
|
+
services={services}
|
|
745
|
+
onComplete={handleBookingComplete}
|
|
746
|
+
/>
|
|
747
|
+
|
|
748
|
+
{bookingData && (
|
|
749
|
+
<SquarePayment
|
|
750
|
+
amount={bookingData.depositAmount}
|
|
751
|
+
onPaymentSuccess={handlePaymentSuccess}
|
|
752
|
+
onPaymentError={handlePaymentError}
|
|
753
|
+
/>
|
|
754
|
+
)}
|
|
755
|
+
</div>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## See Also
|
|
763
|
+
|
|
764
|
+
- [CALLBACKS.md](./CALLBACKS.md) - Event handling patterns
|
|
765
|
+
- [DATA_SHAPES.md](./DATA_SHAPES.md) - Data structures
|
|
766
|
+
- [API.md](./API.md) - Component reference
|