@ticketboothapp/booking 0.1.4 → 0.1.8

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.
Files changed (45) hide show
  1. package/package.json +21 -1
  2. package/src/components/BookingDetails.tsx +546 -0
  3. package/src/components/BookingFlow.tsx +2952 -0
  4. package/src/components/BookingWidget.tsx +7 -5
  5. package/src/components/Calendar.tsx +906 -0
  6. package/src/components/CheckoutModal.tsx +294 -0
  7. package/src/components/CurrencySwitcher.tsx +81 -0
  8. package/src/components/ErrorBoundary.tsx +63 -0
  9. package/src/components/ItineraryBuilder.tsx +83 -0
  10. package/src/components/LanguageSwitcher.tsx +30 -0
  11. package/src/components/ManageBookingView.tsx +4 -2
  12. package/src/components/MealDrinkAddOnSelector.tsx +330 -0
  13. package/src/components/PickupLocationSelector.tsx +1541 -0
  14. package/src/components/PriceBreakdown.tsx +154 -0
  15. package/src/components/PriceSummary.tsx +211 -0
  16. package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
  17. package/src/components/ProductList.tsx +78 -0
  18. package/src/components/TermsAcceptance.tsx +110 -0
  19. package/src/components/WhatsAppPhoneInput.tsx +224 -0
  20. package/src/components/index.ts +31 -0
  21. package/src/contexts/CompanyContext.tsx +8 -20
  22. package/src/index.ts +5 -0
  23. package/src/lib/api.ts +801 -0
  24. package/src/lib/booking-ref.ts +13 -0
  25. package/src/lib/checkout-breakdown.test.ts +70 -0
  26. package/src/lib/checkout-breakdown.ts +69 -0
  27. package/src/lib/constants.ts +17 -0
  28. package/src/lib/currency.ts +88 -0
  29. package/src/lib/env.ts +10 -12
  30. package/src/lib/i18n/config.ts +21 -0
  31. package/src/lib/i18n/index.tsx +144 -0
  32. package/src/lib/i18n/messages/en.json +192 -0
  33. package/src/lib/i18n/messages/fr.json +192 -0
  34. package/src/lib/itinerary-labels.ts +70 -0
  35. package/src/lib/location-calculations.ts +43 -0
  36. package/src/lib/location-utils.ts +139 -0
  37. package/src/lib/map-utils.ts +153 -0
  38. package/src/lib/marker-icons.ts +113 -0
  39. package/src/lib/pickup-location-types.ts +25 -0
  40. package/src/lib/places-api.ts +154 -0
  41. package/src/lib/pricing.ts +466 -0
  42. package/src/lib/theme.ts +83 -0
  43. package/src/lib/utils.ts +9 -0
  44. package/src/types/google-maps.d.ts +2 -0
  45. package/tsconfig.json +8 -2
package/package.json CHANGED
@@ -1,17 +1,37 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "0.1.4",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "sideEffects": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
+ "scripts": {
10
+ "lint": "eslint src --ext .ts,.tsx",
11
+ "typecheck": "tsc -p tsconfig.json --noEmit"
12
+ },
9
13
  "exports": {
10
14
  ".": "./src/index.ts"
11
15
  },
16
+ "dependencies": {
17
+ "@radix-ui/react-select": "^2.2.6",
18
+ "@radix-ui/react-slot": "^1.2.4",
19
+ "@react-google-maps/api": "^2.20.7",
20
+ "@stripe/react-stripe-js": "^3.9.0",
21
+ "@stripe/stripe-js": "^7.9.0",
22
+ "class-variance-authority": "^0.7.1",
23
+ "clsx": "^2.1.1",
24
+ "date-fns": "^4.1.0",
25
+ "date-fns-tz": "^3.2.0",
26
+ "lucide-react": "^0.577.0",
27
+ "tailwind-merge": "^3.5.0"
28
+ },
12
29
  "peerDependencies": {
13
30
  "next": "^15.0.0",
14
31
  "react": "^18.0.0 || ^19.0.0",
15
32
  "react-dom": "^18.0.0 || ^19.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/google.maps": "^3.58.1"
16
36
  }
17
37
  }
@@ -0,0 +1,546 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { loadStripe } from '@stripe/stripe-js';
6
+ import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
7
+ import { formatCurrencyAmount } from '@/lib/currency';
8
+ import { formatBookingRefForDisplay } from '@/lib/booking-ref';
9
+ import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
10
+ import { type Currency } from './CurrencySwitcher';
11
+ import { useTranslations } from '@/lib/i18n';
12
+ import { getStepLabel } from '@/lib/itinerary-labels';
13
+ import { createBalancePaymentIntent, createManagePaymentIntent, type ItineraryDisplayStep } from '@/lib/api';
14
+ import { ENV } from '@/lib/env';
15
+
16
+ const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY ? loadStripe(ENV.STRIPE_PUBLISHABLE_KEY) : null;
17
+
18
+ function isPickupOrDropOffStep(step: { stepType?: string }): boolean {
19
+ return step.stepType === 'pickup' || step.stepType === 'drop_off';
20
+ }
21
+
22
+ function BalancePaymentForm({
23
+ successUrl,
24
+ onClose,
25
+ t,
26
+ balanceAmount,
27
+ currency,
28
+ }: {
29
+ successUrl: string;
30
+ onClose: () => void;
31
+ t: (key: string) => string;
32
+ balanceAmount: number;
33
+ currency: Currency;
34
+ }) {
35
+ const stripe = useStripe();
36
+ const elements = useElements();
37
+ const [loading, setLoading] = useState(false);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ const handleSubmit = async (e: React.FormEvent) => {
41
+ e.preventDefault();
42
+ if (!stripe || !elements) return;
43
+ setLoading(true);
44
+ setError(null);
45
+ const { error: submitError } = await elements.submit();
46
+ if (submitError) {
47
+ setError(submitError.message ?? 'Validation failed');
48
+ setLoading(false);
49
+ return;
50
+ }
51
+ const { error: confirmError } = await stripe.confirmPayment({
52
+ elements,
53
+ confirmParams: { return_url: successUrl },
54
+ });
55
+ if (confirmError) {
56
+ setError(confirmError.message ?? 'Payment failed');
57
+ }
58
+ setLoading(false);
59
+ };
60
+
61
+ return (
62
+ <form onSubmit={handleSubmit} className="space-y-4">
63
+ <PaymentElement />
64
+ {error && (
65
+ <p className="text-sm text-red-600" role="alert">{error}</p>
66
+ )}
67
+ <div className="flex gap-3 pt-2">
68
+ <button
69
+ type="button"
70
+ onClick={onClose}
71
+ className="flex-1 py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50"
72
+ >
73
+ {t('common.cancel') || 'Cancel'}
74
+ </button>
75
+ <button
76
+ type="submit"
77
+ disabled={!stripe || loading}
78
+ className="flex-1 py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
79
+ >
80
+ {loading ? 'Paying...' : `Pay remaining balance (${formatCurrencyAmount(balanceAmount, currency)})`}
81
+ </button>
82
+ </div>
83
+ </form>
84
+ );
85
+ }
86
+
87
+ export interface ReceiptLineItem {
88
+ type: string;
89
+ label: string;
90
+ amount: number;
91
+ quantity?: number | null;
92
+ reference?: string | null;
93
+ }
94
+
95
+ export interface Receipt {
96
+ currency: string;
97
+ lineItems: ReceiptLineItem[];
98
+ subtotalBeforeTax: number;
99
+ taxAmount: number;
100
+ taxRate?: number | null;
101
+ taxIncluded: boolean;
102
+ totalAmount: number;
103
+ promoCode?: string | null;
104
+ promoName?: string | null;
105
+ }
106
+
107
+ export interface PaymentMethodUsage {
108
+ type: string;
109
+ amount: number;
110
+ currency: string;
111
+ displayLabel?: string | null;
112
+ reference?: string | null;
113
+ paidAt?: string | null;
114
+ paymentType?: string | null; // DEPOSIT | BALANCE | FULL
115
+ }
116
+
117
+ export interface PaymentPlan {
118
+ type: string;
119
+ depositAmount: number;
120
+ balanceAmount: number;
121
+ chargeDate?: string | null;
122
+ chargeStrategy: string;
123
+ }
124
+
125
+ export interface PaymentDisplay {
126
+ status: string;
127
+ plan?: PaymentPlan | null;
128
+ methodUsages: PaymentMethodUsage[];
129
+ balanceChargeScheduledAt?: string | null;
130
+ balanceChargeAttempts: number;
131
+ lastBalanceChargeAttempt?: string | null;
132
+ }
133
+
134
+ export interface CancellationPolicySnapshot {
135
+ label: string;
136
+ tiers?: Array<{
137
+ hoursBeforeBooking: number;
138
+ refundPercentage: number;
139
+ }>;
140
+ }
141
+
142
+ export interface Customer {
143
+ firstName?: string | null;
144
+ lastName?: string | null;
145
+ email?: string | null;
146
+ phoneNumber?: string | null;
147
+ }
148
+
149
+ export interface BookingData {
150
+ bookingReference: string;
151
+ productId: string;
152
+ status: string;
153
+ productType?: string | null; // "STANDARD" | "PRIVATE_SHUTTLE" - for deposit receipt UI
154
+ customer?: Customer | null;
155
+ receipt: Receipt;
156
+ payment: PaymentDisplay;
157
+ pickupLocationId?: string | null;
158
+ travelerHotel?: string | null;
159
+ cancellationPolicyId?: string | null;
160
+ cancellationPolicySnapshot?: CancellationPolicySnapshot | null;
161
+ communicationPreference?: string[] | null;
162
+ itineraryDisplay?: ItineraryDisplayStep[] | null;
163
+ createdAt: string;
164
+ }
165
+
166
+ export interface BookingDetailsProps {
167
+ booking: BookingData;
168
+ currency: Currency;
169
+ /** When provided, show a refresh button in the balance-due section (e.g. after returning from Stripe) */
170
+ onRefetch?: () => void;
171
+ }
172
+
173
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- currency kept for API; display uses booking.receipt.currency
174
+ export function BookingDetails({ booking, currency, onRefetch }: BookingDetailsProps) {
175
+ const { t } = useTranslations();
176
+ const { receipt, payment, itineraryDisplay, cancellationPolicySnapshot, communicationPreference } = booking;
177
+ const [showBalanceModal, setShowBalanceModal] = useState(false);
178
+ const [balanceClientSecret, setBalanceClientSecret] = useState<string | null>(null);
179
+ const [balanceLoading, setBalanceLoading] = useState(false);
180
+ const [balanceError, setBalanceError] = useState<string | null>(null);
181
+ const [paymentModalLabel, setPaymentModalLabel] = useState<string>('Pay remaining balance');
182
+
183
+ const isDepositPaid = payment.status === 'DEPOSIT_PAID';
184
+ const isAwaitingPayment = payment.status === 'AWAITING_PAYMENT';
185
+ const balanceAmount = payment.plan?.balanceAmount ?? 0;
186
+ const depositAmount = payment.plan?.depositAmount ?? 0;
187
+ const hasBalanceDue = isDepositPaid && balanceAmount > 0;
188
+ const hasPaymentDue = isAwaitingPayment && (depositAmount > 0 || balanceAmount > 0);
189
+
190
+ const handlePayBalance = async () => {
191
+ const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
192
+ const ln = booking.customer?.lastName?.trim();
193
+ if (!ln) {
194
+ setBalanceError('Last name is required to pay balance');
195
+ return;
196
+ }
197
+ setBalanceLoading(true);
198
+ setBalanceError(null);
199
+ try {
200
+ const res = await createBalancePaymentIntent(ref, ln);
201
+ setBalanceClientSecret(res.clientSecret);
202
+ setPaymentModalLabel('Pay remaining balance');
203
+ setShowBalanceModal(true);
204
+ } catch (err) {
205
+ setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
206
+ } finally {
207
+ setBalanceLoading(false);
208
+ }
209
+ };
210
+
211
+ const handlePayDeposit = async () => {
212
+ const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
213
+ const ln = booking.customer?.lastName?.trim();
214
+ if (!ln) {
215
+ setBalanceError('Last name is required');
216
+ return;
217
+ }
218
+ setBalanceLoading(true);
219
+ setBalanceError(null);
220
+ try {
221
+ const res = await createManagePaymentIntent(ref, ln, 'deposit');
222
+ setBalanceClientSecret(res.clientSecret);
223
+ setPaymentModalLabel('Pay deposit');
224
+ setShowBalanceModal(true);
225
+ } catch (err) {
226
+ setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
227
+ } finally {
228
+ setBalanceLoading(false);
229
+ }
230
+ };
231
+
232
+ const handlePayFull = async () => {
233
+ const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
234
+ const ln = booking.customer?.lastName?.trim();
235
+ if (!ln) {
236
+ setBalanceError('Last name is required');
237
+ return;
238
+ }
239
+ setBalanceLoading(true);
240
+ setBalanceError(null);
241
+ try {
242
+ const res = await createManagePaymentIntent(ref, ln, 'full');
243
+ setBalanceClientSecret(res.clientSecret);
244
+ setPaymentModalLabel('Pay full balance');
245
+ setShowBalanceModal(true);
246
+ } catch (err) {
247
+ setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
248
+ } finally {
249
+ setBalanceLoading(false);
250
+ }
251
+ };
252
+
253
+ return (
254
+ <div className="max-w-4xl mx-auto px-4 py-8 space-y-8">
255
+ {/* Header */}
256
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
257
+ <div className="flex items-center gap-4 mb-4">
258
+ <div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center">
259
+ <svg className="w-8 h-8 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
260
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
261
+ </svg>
262
+ </div>
263
+ <div>
264
+ <h1 className="text-2xl sm:text-3xl font-bold text-stone-900">Booking Confirmed</h1>
265
+ <p className="text-stone-600">Reference: {formatBookingRefForDisplay(booking.bookingReference)}</p>
266
+ </div>
267
+ </div>
268
+ {booking.customer && (
269
+ <p className="text-stone-700">
270
+ Thank you{booking.customer.firstName ? `, ${booking.customer.firstName}` : ''}!
271
+ A confirmation has been sent to {booking.customer.email || 'your email'}.
272
+ </p>
273
+ )}
274
+ </div>
275
+
276
+ {/* Your Itinerary */}
277
+ {itineraryDisplay && itineraryDisplay.length > 0 && (
278
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
279
+ <h2 className="text-xl font-bold text-stone-900 mb-4 flex items-center gap-2">
280
+ <svg className="w-5 h-5 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
281
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
282
+ </svg>
283
+ Your Itinerary
284
+ </h2>
285
+ <div className="space-y-3">
286
+ {itineraryDisplay.map((step, index) => {
287
+ const isUncertainStep = !booking.pickupLocationId && isPickupOrDropOffStep(step);
288
+ const label = getStepLabel(step, t);
289
+ return (
290
+ <div key={index} className="flex justify-between items-start gap-4 border-l-2 border-stone-200 pl-4">
291
+ <span className="text-stone-700 font-medium">
292
+ {label}
293
+ </span>
294
+ <span className="shrink-0 flex items-center gap-1.5">
295
+ {isUncertainStep && !step.time ? (
296
+ <>
297
+ <span
298
+ className="text-stone-400 cursor-help"
299
+ title="Time not set — add your pickup location in Manage Booking for an estimate."
300
+ >
301
+ <svg className="w-4 h-4 inline align-middle" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
302
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
303
+ </svg>
304
+ </span>
305
+ <span className="text-stone-400 font-medium">TBD</span>
306
+ </>
307
+ ) : (
308
+ step.time && <span className="text-stone-600">{step.time}</span>
309
+ )}
310
+ </span>
311
+ </div>
312
+ );
313
+ })}
314
+ </div>
315
+ {!booking.pickupLocationId && itineraryDisplay.some(isPickupOrDropOffStep) && (
316
+ <p className="mt-4 text-sm text-stone-500">
317
+ <Link
318
+ href={`/manage?ref=${encodeURIComponent(booking.bookingReference)}${booking.customer?.lastName ? `&lastName=${encodeURIComponent(booking.customer.lastName)}` : ''}`}
319
+ className="text-stone-600 underline hover:text-stone-800"
320
+ >
321
+ Set or update your pickup location
322
+ </Link>
323
+ {' — we’ll show estimated pickup and drop-off times.'}
324
+ </p>
325
+ )}
326
+ </div>
327
+ )}
328
+
329
+ {/* Payment Summary — shared PriceSummary component */}
330
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
331
+ <h2 className="text-xl font-bold text-stone-900 mb-4 flex items-center gap-2">
332
+ <svg className="w-5 h-5 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
333
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
334
+ </svg>
335
+ Payment Summary
336
+ </h2>
337
+ <PriceSummary
338
+ lines={receipt.lineItems.map((item): PriceSummaryLine => ({
339
+ kind: 'line',
340
+ label: item.label,
341
+ amount: item.amount,
342
+ type: item.type,
343
+ quantity: item.quantity,
344
+ }))}
345
+ total={receipt.totalAmount}
346
+ currency={receipt.currency as Currency}
347
+ locale="en"
348
+ subtotal={receipt.taxAmount > 0 ? receipt.subtotalBeforeTax : undefined}
349
+ size="base"
350
+ subtotalSpacing="relaxed"
351
+ t={t}
352
+ />
353
+
354
+ {/* Payments — Deposit/Balance for private shuttle; Card/Gift card/No payment for pay-in-full */}
355
+ {payment.methodUsages && payment.methodUsages.length > 0 && (
356
+ <div className="mt-6 pt-4 border-t border-stone-200">
357
+ <h3 className="text-sm font-semibold text-stone-700 mb-2">
358
+ {payment.plan?.type === 'DEPOSIT' ? 'Payments' : 'Payment'}
359
+ </h3>
360
+ {payment.methodUsages.map((usage, index) => {
361
+ const isDepositPlan = payment.plan?.type === 'DEPOSIT';
362
+ const label = isDepositPlan
363
+ ? (usage.paymentType === 'BALANCE' ? 'Balance' : 'Deposit')
364
+ : (usage.type === 'CARD' ? (usage.displayLabel || 'Card') : usage.type === 'GIFT_CARD' ? (usage.displayLabel || 'Gift Card') : usage.displayLabel || 'No payment');
365
+ return (
366
+ <div key={index} className="flex justify-between text-stone-600 text-sm">
367
+ <span>{label}{isDepositPlan && usage.displayLabel ? ` (${usage.displayLabel})` : ''}</span>
368
+ <span>{formatCurrencyAmount(usage.amount, usage.currency as Currency)}</span>
369
+ </div>
370
+ );
371
+ })}
372
+ </div>
373
+ )}
374
+
375
+ {/* Payment status */}
376
+ <div className="mt-4 pt-4 border-t border-stone-200">
377
+ <div className="flex justify-between text-sm">
378
+ <span className="text-stone-600">Status</span>
379
+ <span className={`font-semibold ${
380
+ payment.status === 'FULLY_PAID' ? 'text-emerald-600' :
381
+ payment.status === 'DEPOSIT_PAID' ? 'text-amber-600' :
382
+ 'text-stone-600'
383
+ }`}>
384
+ {payment.status === 'FULLY_PAID' && 'Fully Paid'}
385
+ {payment.status === 'DEPOSIT_PAID' && 'Deposit Paid'}
386
+ {payment.status === 'AWAITING_PAYMENT' && 'Payment Due'}
387
+ {payment.status === 'PAYMENT_FAILED' && 'Payment Failed'}
388
+ </span>
389
+ </div>
390
+ {hasPaymentDue && (
391
+ <div className="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-4">
392
+ <p className="font-semibold">Payment due</p>
393
+ <p className="text-xs mt-1 text-amber-600">
394
+ Deposit: {formatCurrencyAmount(depositAmount, receipt.currency as Currency)} • Balance: {formatCurrencyAmount(balanceAmount, receipt.currency as Currency)}
395
+ </p>
396
+ <p className="text-xs mt-2 text-amber-600">Pay now or the balance will be charged automatically before your booking.</p>
397
+ {onRefetch && (
398
+ <p className="text-xs mt-2">
399
+ <button type="button" onClick={onRefetch} className="underline hover:no-underline text-amber-700">
400
+ Just paid? Refresh to update
401
+ </button>
402
+ </p>
403
+ )}
404
+ {balanceError && (
405
+ <p className="text-red-600 text-xs mt-2" role="alert">{balanceError}</p>
406
+ )}
407
+ <div className="mt-3 flex flex-col gap-2">
408
+ <button
409
+ type="button"
410
+ onClick={handlePayDeposit}
411
+ disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
412
+ className="w-full py-2.5 px-4 bg-amber-600 hover:bg-amber-700 disabled:bg-stone-300 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
413
+ >
414
+ {balanceLoading ? 'Loading...' : `Pay deposit (${formatCurrencyAmount(depositAmount, receipt.currency as Currency)})`}
415
+ </button>
416
+ <button
417
+ type="button"
418
+ onClick={handlePayFull}
419
+ disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
420
+ className="w-full py-2.5 px-4 border border-amber-600 text-amber-700 hover:bg-amber-50 disabled:opacity-50 disabled:cursor-not-allowed font-semibold rounded-lg transition-colors"
421
+ >
422
+ Pay full balance ({formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)})
423
+ </button>
424
+ </div>
425
+ </div>
426
+ )}
427
+ {hasBalanceDue && !hasPaymentDue && (
428
+ <div className="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-4">
429
+ <p className="font-semibold">Balance Due: {formatCurrencyAmount(balanceAmount, receipt.currency as Currency)}</p>
430
+ {payment.balanceChargeScheduledAt && (
431
+ <p className="text-xs mt-1 text-amber-600">Scheduled to be charged on: {new Date(payment.balanceChargeScheduledAt).toLocaleDateString()}</p>
432
+ )}
433
+ <p className="text-xs mt-2 text-amber-600">Pay now to complete your booking.</p>
434
+ {onRefetch && (
435
+ <p className="text-xs mt-2">
436
+ <button type="button" onClick={onRefetch} className="underline hover:no-underline text-amber-700">
437
+ Just paid? Refresh to update
438
+ </button>
439
+ </p>
440
+ )}
441
+ {balanceError && (
442
+ <p className="text-red-600 text-xs mt-2" role="alert">{balanceError}</p>
443
+ )}
444
+ <button
445
+ type="button"
446
+ onClick={handlePayBalance}
447
+ disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
448
+ className="mt-3 w-full py-2.5 px-4 bg-amber-600 hover:bg-amber-700 disabled:bg-stone-300 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
449
+ >
450
+ {balanceLoading ? 'Loading...' : `Pay remaining balance (${formatCurrencyAmount(balanceAmount, receipt.currency as Currency)})`}
451
+ </button>
452
+ </div>
453
+ )}
454
+ </div>
455
+ </div>
456
+
457
+ {/* Cancellation Policy */}
458
+ {cancellationPolicySnapshot && (
459
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
460
+ <h2 className="text-xl font-bold text-stone-900 mb-4">Cancellation Policy</h2>
461
+ <p className="text-stone-700 font-medium mb-2">{cancellationPolicySnapshot.label}</p>
462
+ {cancellationPolicySnapshot.tiers && cancellationPolicySnapshot.tiers.length > 0 && (
463
+ <div className="space-y-2 text-sm text-stone-600">
464
+ {cancellationPolicySnapshot.tiers.map((tier, index) => (
465
+ <p key={index}>
466
+ • {tier.hoursBeforeBooking}+ hours before: {tier.refundPercentage}% refund
467
+ </p>
468
+ ))}
469
+ </div>
470
+ )}
471
+ </div>
472
+ )}
473
+
474
+ {/* Contact & Communication */}
475
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
476
+ <h2 className="text-xl font-bold text-stone-900 mb-4">Contact Information</h2>
477
+ {booking.customer && (
478
+ <div className="space-y-2 text-stone-700">
479
+ {booking.customer.email && <p><span className="font-medium">Email:</span> {booking.customer.email}</p>}
480
+ {booking.customer.phoneNumber && <p><span className="font-medium">Phone:</span> {booking.customer.phoneNumber}</p>}
481
+ </div>
482
+ )}
483
+ {communicationPreference && communicationPreference.length > 0 && (
484
+ <div className="mt-4 pt-4 border-t border-stone-200">
485
+ <p className="text-sm text-stone-600">
486
+ <span className="font-medium">Preferred communication:</span> {communicationPreference.join(', ')}
487
+ </p>
488
+ </div>
489
+ )}
490
+ </div>
491
+
492
+ {/* Pickup Location */}
493
+ {(booking.pickupLocationId || booking.travelerHotel) && (
494
+ <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
495
+ <h2 className="text-xl font-bold text-stone-900 mb-4">Pickup Details</h2>
496
+ <p className="text-stone-700">
497
+ {booking.travelerHotel || `Location ID: ${booking.pickupLocationId}`}
498
+ </p>
499
+ </div>
500
+ )}
501
+
502
+ {/* Balance payment modal */}
503
+ {showBalanceModal && balanceClientSecret && stripePromise && (
504
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
505
+ <div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col">
506
+ <div className="p-6 border-b border-stone-200 flex-shrink-0">
507
+ <div className="flex justify-between items-start">
508
+ <h3 className="text-lg font-semibold text-stone-900">{paymentModalLabel}</h3>
509
+ <button
510
+ type="button"
511
+ onClick={() => { setShowBalanceModal(false); setBalanceClientSecret(null); setBalanceError(null); setPaymentModalLabel('Pay remaining balance'); }}
512
+ className="text-stone-400 hover:text-stone-600 p-1"
513
+ aria-label="Close"
514
+ >
515
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
516
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
517
+ </svg>
518
+ </button>
519
+ </div>
520
+ <p className="text-sm text-stone-600 mt-1">
521
+ {formatCurrencyAmount(hasPaymentDue ? (paymentModalLabel === 'Pay deposit' ? depositAmount : receipt.totalAmount) : balanceAmount, receipt.currency as Currency)} due
522
+ </p>
523
+ </div>
524
+ <div className="p-6 overflow-auto flex-1">
525
+ <Elements
526
+ stripe={stripePromise}
527
+ options={{
528
+ clientSecret: balanceClientSecret,
529
+ appearance: { theme: 'stripe' as const, variables: { colorPrimary: '#059669', borderRadius: '8px' } },
530
+ }}
531
+ >
532
+ <BalancePaymentForm
533
+ successUrl={`${typeof window !== 'undefined' ? window.location.origin : ''}/manage?ref=${encodeURIComponent(formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference)}&lastName=${encodeURIComponent(booking.customer?.lastName || '')}&payment=balance_success`}
534
+ onClose={() => { setShowBalanceModal(false); setBalanceClientSecret(null); setBalanceError(null); setPaymentModalLabel('Pay remaining balance'); }}
535
+ t={t}
536
+ balanceAmount={hasPaymentDue ? (paymentModalLabel === 'Pay deposit' ? depositAmount : receipt.totalAmount) : balanceAmount}
537
+ currency={receipt.currency as Currency}
538
+ />
539
+ </Elements>
540
+ </div>
541
+ </div>
542
+ </div>
543
+ )}
544
+ </div>
545
+ );
546
+ }