@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.
- package/package.json +21 -1
- package/src/components/BookingDetails.tsx +546 -0
- package/src/components/BookingFlow.tsx +2952 -0
- package/src/components/BookingWidget.tsx +7 -5
- package/src/components/Calendar.tsx +906 -0
- package/src/components/CheckoutModal.tsx +294 -0
- package/src/components/CurrencySwitcher.tsx +81 -0
- package/src/components/ErrorBoundary.tsx +63 -0
- package/src/components/ItineraryBuilder.tsx +83 -0
- package/src/components/LanguageSwitcher.tsx +30 -0
- package/src/components/ManageBookingView.tsx +4 -2
- package/src/components/MealDrinkAddOnSelector.tsx +330 -0
- package/src/components/PickupLocationSelector.tsx +1541 -0
- package/src/components/PriceBreakdown.tsx +154 -0
- package/src/components/PriceSummary.tsx +211 -0
- package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
- package/src/components/ProductList.tsx +78 -0
- package/src/components/TermsAcceptance.tsx +110 -0
- package/src/components/WhatsAppPhoneInput.tsx +224 -0
- package/src/components/index.ts +31 -0
- package/src/contexts/CompanyContext.tsx +8 -20
- package/src/index.ts +5 -0
- package/src/lib/api.ts +801 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/checkout-breakdown.test.ts +70 -0
- package/src/lib/checkout-breakdown.ts +69 -0
- package/src/lib/constants.ts +17 -0
- package/src/lib/currency.ts +88 -0
- package/src/lib/env.ts +10 -12
- package/src/lib/i18n/config.ts +21 -0
- package/src/lib/i18n/index.tsx +144 -0
- package/src/lib/i18n/messages/en.json +192 -0
- package/src/lib/i18n/messages/fr.json +192 -0
- package/src/lib/itinerary-labels.ts +70 -0
- package/src/lib/location-calculations.ts +43 -0
- package/src/lib/location-utils.ts +139 -0
- package/src/lib/map-utils.ts +153 -0
- package/src/lib/marker-icons.ts +113 -0
- package/src/lib/pickup-location-types.ts +25 -0
- package/src/lib/places-api.ts +154 -0
- package/src/lib/pricing.ts +466 -0
- package/src/lib/theme.ts +83 -0
- package/src/lib/utils.ts +9 -0
- package/src/types/google-maps.d.ts +2 -0
- package/tsconfig.json +8 -2
package/package.json
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ticketboothapp/booking",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|