@instockng/storefront-ui 1.0.10 → 1.0.12
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 +26 -0
- package/dist/components/Checkout.d.ts.map +1 -1
- package/dist/components/ShoppingCart.d.ts.map +1 -1
- package/dist/contexts/CartContext.d.ts.map +1 -1
- package/dist/index10.mjs +144 -141
- package/dist/index101.mjs +1 -1
- package/dist/index102.mjs +3 -3
- package/dist/index103.mjs +3 -3
- package/dist/index105.mjs +1 -1
- package/dist/index111.mjs +1 -1
- package/dist/index20.mjs +2 -2
- package/dist/index21.mjs +1 -1
- package/dist/index28.mjs +11 -11
- package/dist/index3.mjs +88 -78
- package/dist/index37.mjs +1 -1
- package/dist/index41.mjs +36 -23
- package/dist/index42.mjs +44 -36
- package/dist/index43.mjs +99 -44
- package/dist/index44.mjs +112 -99
- package/dist/index45.mjs +44 -80
- package/dist/index46.mjs +64 -53
- package/dist/index47.mjs +65 -48
- package/dist/index48.mjs +54 -73
- package/dist/index49.mjs +52 -63
- package/dist/index50.mjs +14 -70
- package/dist/index51.mjs +13 -14
- package/dist/index52.mjs +58 -13
- package/dist/index53.mjs +101 -34
- package/dist/index54.mjs +99 -95
- package/dist/index55.mjs +22 -132
- package/dist/index62.mjs +30 -231
- package/dist/index63.mjs +42 -5
- package/dist/index64.mjs +228 -127
- package/dist/index65.mjs +4 -66
- package/dist/index66.mjs +124 -77
- package/dist/index67.mjs +65 -26
- package/dist/index68.mjs +84 -6
- package/dist/index69.mjs +26 -72
- package/dist/index70.mjs +8 -3
- package/dist/index71.mjs +75 -2
- package/dist/index72.mjs +3 -82
- package/dist/index73.mjs +2 -54
- package/dist/index74.mjs +82 -5
- package/dist/index75.mjs +53 -4
- package/dist/index76.mjs +5 -178
- package/dist/index77.mjs +5 -53
- package/dist/index78.mjs +178 -68
- package/dist/index79.mjs +50 -31
- package/dist/index8.mjs +8 -7
- package/dist/index80.mjs +69 -43
- package/dist/index81.mjs +2 -2
- package/dist/index82.mjs +1 -1
- package/dist/index83.mjs +6 -6
- package/dist/index84.mjs +2 -2
- package/dist/index85.mjs +2 -2
- package/dist/index87.mjs +2 -2
- package/dist/index88.mjs +2 -2
- package/dist/index89.mjs +1 -1
- package/dist/index91.mjs +4 -4
- package/dist/index92.mjs +3 -3
- package/dist/index93.mjs +12 -30
- package/dist/index94.mjs +7 -11
- package/dist/index95.mjs +30 -3
- package/dist/index96.mjs +10 -3
- package/dist/index97.mjs +4 -13
- package/dist/index98.mjs +4 -7
- package/dist/index99.mjs +1 -1
- package/dist/styles.css +1 -0
- package/package.json +14 -13
- package/src/components/CartItem.stories.tsx +94 -0
- package/src/components/CartItem.tsx +141 -0
- package/src/components/Checkout.stories.tsx +380 -0
- package/src/components/Checkout.tsx +954 -0
- package/src/components/DiscountCodeInput.stories.tsx +76 -0
- package/src/components/DiscountCodeInput.tsx +162 -0
- package/src/components/OrderConfirmation.stories.tsx +142 -0
- package/src/components/OrderConfirmation.tsx +301 -0
- package/src/components/ProductCard.stories.tsx +112 -0
- package/src/components/ProductCard.tsx +195 -0
- package/src/components/ProductGrid.stories.tsx +137 -0
- package/src/components/ProductGrid.tsx +141 -0
- package/src/components/ShoppingCart.stories.tsx +459 -0
- package/src/components/ShoppingCart.tsx +263 -0
- package/src/components/ui/badge.tsx +37 -0
- package/src/components/ui/button.tsx +71 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/form-input.tsx +78 -0
- package/src/components/ui/form-select.tsx +73 -0
- package/src/components/ui/modal.tsx +181 -0
- package/src/contexts/CartContext.tsx +316 -0
- package/src/hooks/usePaystackPayment.ts +137 -0
- package/src/index.ts +51 -0
- package/src/lib/utils.ts +45 -0
- package/src/paystack.svg +67 -0
- package/src/providers/StorefrontProvider.tsx +70 -0
- package/src/styles.css +1 -0
- package/src/test-utils/MockCartProvider.tsx +424 -0
- package/src/vite-env.d.ts +12 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checkout Component
|
|
5
|
+
*
|
|
6
|
+
* A multi-step floating modal checkout with smooth sliding transitions.
|
|
7
|
+
* Steps: 1) Customer Info, 2) Delivery Address, 3) Payment
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
11
|
+
import * as Yup from 'yup';
|
|
12
|
+
import { useGetDeliveryZones } from '@oms/api-client';
|
|
13
|
+
import type { Order } from '@oms/api-client';
|
|
14
|
+
import { useCart } from '../contexts/CartContext';
|
|
15
|
+
import { Modal } from './ui/modal';
|
|
16
|
+
import { FormInput } from './ui/form-input';
|
|
17
|
+
import { FormSelect } from './ui/form-select';
|
|
18
|
+
import { Button } from './ui/button';
|
|
19
|
+
import {
|
|
20
|
+
Loader2,
|
|
21
|
+
Mail,
|
|
22
|
+
Phone,
|
|
23
|
+
Zap,
|
|
24
|
+
Banknote,
|
|
25
|
+
ChevronDown,
|
|
26
|
+
ChevronLeft,
|
|
27
|
+
ChevronRight,
|
|
28
|
+
CheckCircle,
|
|
29
|
+
Package,
|
|
30
|
+
} from 'lucide-react';
|
|
31
|
+
import { formatCurrency, cn } from '../lib/utils';
|
|
32
|
+
import PaystackSVG from "../paystack.svg"
|
|
33
|
+
import { usePaystackPayment } from '../hooks/usePaystackPayment';
|
|
34
|
+
|
|
35
|
+
// Utility to clean phone number by removing all non-digit characters
|
|
36
|
+
const cleanPhoneNumber = (phone: string): string => {
|
|
37
|
+
return phone.replace(/\D/g, '');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Yup validation schemas for each step
|
|
41
|
+
const customerSchema = Yup.object({
|
|
42
|
+
firstName: Yup.string()
|
|
43
|
+
.required('First name is required')
|
|
44
|
+
.min(2, 'First name must be at least 2 characters')
|
|
45
|
+
.max(50, 'First name must be less than 50 characters'),
|
|
46
|
+
lastName: Yup.string()
|
|
47
|
+
.required('Last name is required')
|
|
48
|
+
.min(2, 'Last name must be at least 2 characters')
|
|
49
|
+
.max(50, 'Last name must be less than 50 characters'),
|
|
50
|
+
email: Yup.string()
|
|
51
|
+
.required('Email is required')
|
|
52
|
+
.email('Please enter a valid email address'),
|
|
53
|
+
phone: Yup.string()
|
|
54
|
+
.required('Phone number is required')
|
|
55
|
+
.test('nigerian-phone', 'Please enter a valid Nigerian phone number (e.g. 0812 564 8668)', (value) => {
|
|
56
|
+
if (!value) return false;
|
|
57
|
+
const numbers = cleanPhoneNumber(value);
|
|
58
|
+
|
|
59
|
+
// Valid Nigerian mobile patterns
|
|
60
|
+
const validPatterns = [
|
|
61
|
+
/^0[789]\d{9}$/, // 0xxxxxxxxx (11 digits total)
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
return validPatterns.some(pattern => pattern.test(numbers));
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const deliverySchema = Yup.object({
|
|
69
|
+
address: Yup.string()
|
|
70
|
+
.required('Street address is required')
|
|
71
|
+
.min(10, 'Please provide a complete address'),
|
|
72
|
+
city: Yup.string()
|
|
73
|
+
.required('City is required')
|
|
74
|
+
.min(3, 'City name must be at least 3 characters'),
|
|
75
|
+
state: Yup.string()
|
|
76
|
+
.required('State is required')
|
|
77
|
+
.test('not-empty', 'State is required', (value) => {
|
|
78
|
+
return !!value && value.trim() !== '';
|
|
79
|
+
}),
|
|
80
|
+
deliveryZoneId: Yup.string()
|
|
81
|
+
.required('Delivery zone is required')
|
|
82
|
+
.test('not-empty', 'Delivery zone is required', (value) => {
|
|
83
|
+
return !!value && value.trim() !== '';
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const paymentSchema = Yup.object({
|
|
88
|
+
paymentMethod: Yup.string()
|
|
89
|
+
.oneOf(['cod', 'online'], 'Please select a payment method')
|
|
90
|
+
.required('Payment method is required'),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export type PaymentMethod = 'cod' | 'online';
|
|
94
|
+
|
|
95
|
+
export interface CheckoutFormData {
|
|
96
|
+
firstName: string;
|
|
97
|
+
lastName: string;
|
|
98
|
+
email: string;
|
|
99
|
+
phone: string;
|
|
100
|
+
address: string;
|
|
101
|
+
city: string;
|
|
102
|
+
state?: string;
|
|
103
|
+
deliveryZoneId: string;
|
|
104
|
+
paymentMethod: PaymentMethod;
|
|
105
|
+
notes?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CheckoutProps {
|
|
109
|
+
/** Controls modal visibility */
|
|
110
|
+
isOpen: boolean;
|
|
111
|
+
/** Callback when modal should close */
|
|
112
|
+
onClose: () => void;
|
|
113
|
+
/** Initial form data */
|
|
114
|
+
initialData?: Partial<CheckoutFormData>;
|
|
115
|
+
/** Callback on successful checkout */
|
|
116
|
+
onSuccess?: (orderId: string) => void;
|
|
117
|
+
/** Callback on error */
|
|
118
|
+
onError?: (error: Error) => void;
|
|
119
|
+
/** Submit button text */
|
|
120
|
+
submitButtonText?: string;
|
|
121
|
+
/** States available for selection */
|
|
122
|
+
states?: Array<{ value: string; label: string }>;
|
|
123
|
+
/** Refund policy content */
|
|
124
|
+
refundPolicy?: {
|
|
125
|
+
title?: string;
|
|
126
|
+
policies?: string[];
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Step = 'customer' | 'delivery' | 'payment';
|
|
131
|
+
|
|
132
|
+
export function Checkout({
|
|
133
|
+
isOpen,
|
|
134
|
+
onClose,
|
|
135
|
+
initialData,
|
|
136
|
+
onSuccess,
|
|
137
|
+
onError,
|
|
138
|
+
submitButtonText = 'Place Order',
|
|
139
|
+
refundPolicy,
|
|
140
|
+
}: CheckoutProps) {
|
|
141
|
+
|
|
142
|
+
const paystackPublicKey = "pk_live_dfb74efb5f74d2acbc253d5ca396ab9015ef0fa7";
|
|
143
|
+
|
|
144
|
+
const [currentStep, setCurrentStep] = useState<Step>('customer');
|
|
145
|
+
const [selectedStateId, setSelectedStateId] = useState<string | undefined>(undefined);
|
|
146
|
+
const [selectedDeliveryZoneId, setSelectedDeliveryZoneId] = useState<string | undefined>(undefined);
|
|
147
|
+
const [completedOrder, setCompletedOrder] = useState<Order | null>(null);
|
|
148
|
+
|
|
149
|
+
// Get cart from CartProvider context (required)
|
|
150
|
+
const { refetch: refetchCart, isLoading: isLoadingCart, cart, updateCartMutation, checkoutMutation } = useCart();
|
|
151
|
+
|
|
152
|
+
// Paystack payment integration
|
|
153
|
+
const paystack = usePaystackPayment({
|
|
154
|
+
publicKey: paystackPublicKey || '',
|
|
155
|
+
onSuccess: async (response) => {
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const order = await checkout.mutateAsync({
|
|
159
|
+
firstName: formData.firstName,
|
|
160
|
+
lastName: formData.lastName,
|
|
161
|
+
email: formData.email,
|
|
162
|
+
phone: formData.phone,
|
|
163
|
+
address: formData.address,
|
|
164
|
+
city: formData.city,
|
|
165
|
+
deliveryZoneId: selectedDeliveryZoneId || formData.deliveryZoneId,
|
|
166
|
+
paymentMethod: formData.paymentMethod,
|
|
167
|
+
paystackReference: response.reference,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Check if response is an error
|
|
171
|
+
if ('error' in order) {
|
|
172
|
+
const errorObj = order.error as { message?: string };
|
|
173
|
+
onError?.(new Error(errorObj?.message || 'Checkout failed'));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Store the completed order to show success view
|
|
178
|
+
setCompletedOrder(order as Order);
|
|
179
|
+
onSuccess?.(order.id);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
onError?.(error as Error);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
onClose: () => {
|
|
185
|
+
// User closed the payment popup without completing
|
|
186
|
+
console.log('Payment popup closed by user');
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (isOpen) {
|
|
192
|
+
refetchCart();
|
|
193
|
+
}
|
|
194
|
+
}, [isOpen, refetchCart]);
|
|
195
|
+
|
|
196
|
+
const { data: states } = useGetDeliveryZones(cart?.brand?.id || '', {
|
|
197
|
+
enabled: !!cart?.brand?.id,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const selectedState = useMemo(() => {
|
|
201
|
+
return states?.find((state) => state.id === selectedStateId);
|
|
202
|
+
}, [states, selectedStateId]);
|
|
203
|
+
|
|
204
|
+
// Use checkout mutation from cart context
|
|
205
|
+
const checkout = checkoutMutation;
|
|
206
|
+
|
|
207
|
+
// Reset state when modal closes
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!isOpen) {
|
|
210
|
+
setCompletedOrder(null);
|
|
211
|
+
setCurrentStep('customer');
|
|
212
|
+
checkout.reset();
|
|
213
|
+
}
|
|
214
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
215
|
+
}, [isOpen]);
|
|
216
|
+
|
|
217
|
+
const [formData, setFormData] = useState<CheckoutFormData>({
|
|
218
|
+
firstName: initialData?.firstName || '',
|
|
219
|
+
lastName: initialData?.lastName || '',
|
|
220
|
+
email: initialData?.email || '',
|
|
221
|
+
phone: initialData?.phone || '',
|
|
222
|
+
address: initialData?.address || '',
|
|
223
|
+
city: initialData?.city || '',
|
|
224
|
+
deliveryZoneId: initialData?.deliveryZoneId || '',
|
|
225
|
+
paymentMethod: initialData?.paymentMethod || 'online',
|
|
226
|
+
notes: initialData?.notes || '',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Reset to first step when modal opens
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (isOpen) {
|
|
232
|
+
setCurrentStep('customer');
|
|
233
|
+
}
|
|
234
|
+
}, [isOpen]);
|
|
235
|
+
|
|
236
|
+
// Sync form data with cart data only once when modal opens
|
|
237
|
+
// Use a ref to track if we've already synced to avoid overwriting user input
|
|
238
|
+
const [hasLoadedCartData, setHasLoadedCartData] = useState(false);
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (isOpen && cart && !initialData && !hasLoadedCartData) {
|
|
242
|
+
setFormData((prev) => ({
|
|
243
|
+
...prev,
|
|
244
|
+
firstName: cart.customerFirstName || prev.firstName,
|
|
245
|
+
lastName: cart.customerLastName || prev.lastName,
|
|
246
|
+
email: cart.customerEmail || prev.email,
|
|
247
|
+
phone: cart.customerPhone || prev.phone,
|
|
248
|
+
deliveryZoneId: cart.deliveryZone?.id || prev.deliveryZoneId,
|
|
249
|
+
}));
|
|
250
|
+
setHasLoadedCartData(true);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Reset the flag when modal closes
|
|
254
|
+
if (!isOpen) {
|
|
255
|
+
setHasLoadedCartData(false);
|
|
256
|
+
}
|
|
257
|
+
}, [isOpen, cart, initialData, hasLoadedCartData]);
|
|
258
|
+
|
|
259
|
+
// Reset payment method when delivery zone changes
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (selectedDeliveryZoneId) {
|
|
262
|
+
// Find the selected zone to check available payment methods
|
|
263
|
+
const zone = selectedState?.zones.find(z => z.id === selectedDeliveryZoneId);
|
|
264
|
+
|
|
265
|
+
if (zone) {
|
|
266
|
+
// Reset payment method to allow user to choose based on new zone
|
|
267
|
+
setFormData((prev) => ({
|
|
268
|
+
...prev,
|
|
269
|
+
paymentMethod: zone.allowOnline ? 'online' : 'cod',
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}, [selectedDeliveryZoneId, selectedState]);
|
|
274
|
+
|
|
275
|
+
const [formErrors, setFormErrors] = useState<Partial<Record<keyof CheckoutFormData, string>>>({});
|
|
276
|
+
const [isRefundAccordionOpen, setIsRefundAccordionOpen] = useState(false);
|
|
277
|
+
|
|
278
|
+
const handleInputChange = (
|
|
279
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
|
280
|
+
) => {
|
|
281
|
+
const { name, value } = e.target;
|
|
282
|
+
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
283
|
+
// Clear error when user starts typing
|
|
284
|
+
if (formErrors[name as keyof CheckoutFormData]) {
|
|
285
|
+
setFormErrors((prev) => ({ ...prev, [name]: undefined }));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Auto-save customer fields on blur
|
|
290
|
+
const handleCustomerFieldBlur = useCallback(
|
|
291
|
+
(field: 'firstName' | 'lastName' | 'email' | 'phone') => {
|
|
292
|
+
const value = formData[field];
|
|
293
|
+
if (!value) return; // Don't save empty values
|
|
294
|
+
|
|
295
|
+
// Use proper type from updateCartMutation
|
|
296
|
+
type UpdateCartParams = Parameters<typeof updateCartMutation.mutate>[0];
|
|
297
|
+
const updateData: Partial<UpdateCartParams> = {};
|
|
298
|
+
|
|
299
|
+
if (field === 'firstName') updateData.customerFirstName = value;
|
|
300
|
+
else if (field === 'lastName') updateData.customerLastName = value;
|
|
301
|
+
else if (field === 'email') updateData.customerEmail = value;
|
|
302
|
+
else if (field === 'phone') updateData.customerPhone = value;
|
|
303
|
+
|
|
304
|
+
updateCartMutation.mutate(updateData);
|
|
305
|
+
},
|
|
306
|
+
[formData, updateCartMutation]
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const validateStep = (step: Step): boolean => {
|
|
310
|
+
const errors: Partial<Record<keyof CheckoutFormData, string>> = {};
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
if (step === 'customer') {
|
|
314
|
+
customerSchema.validateSync(formData, { abortEarly: false });
|
|
315
|
+
} else if (step === 'delivery') {
|
|
316
|
+
const deliveryData = {
|
|
317
|
+
address: formData.address,
|
|
318
|
+
city: formData.city,
|
|
319
|
+
state: selectedStateId || formData.state || '',
|
|
320
|
+
deliveryZoneId: selectedDeliveryZoneId || formData.deliveryZoneId || '',
|
|
321
|
+
};
|
|
322
|
+
deliverySchema.validateSync(deliveryData, { abortEarly: false });
|
|
323
|
+
} else if (step === 'payment') {
|
|
324
|
+
paymentSchema.validateSync(formData, { abortEarly: false });
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err instanceof Yup.ValidationError) {
|
|
328
|
+
err.inner.forEach((error) => {
|
|
329
|
+
if (error.path) {
|
|
330
|
+
errors[error.path as keyof CheckoutFormData] = error.message;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setFormErrors(errors);
|
|
337
|
+
return Object.keys(errors).length === 0;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const goToNextStep = () => {
|
|
341
|
+
if (!validateStep(currentStep)) return;
|
|
342
|
+
|
|
343
|
+
if (currentStep === 'customer') {
|
|
344
|
+
setCurrentStep('delivery');
|
|
345
|
+
} else if (currentStep === 'delivery') {
|
|
346
|
+
setCurrentStep('payment');
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const goToPreviousStep = () => {
|
|
351
|
+
if (currentStep === 'payment') {
|
|
352
|
+
setCurrentStep('delivery');
|
|
353
|
+
} else if (currentStep === 'delivery') {
|
|
354
|
+
setCurrentStep('customer');
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handleCheckout = async () => {
|
|
359
|
+
// Validate all steps before checkout
|
|
360
|
+
if (!validateStep('customer')) {
|
|
361
|
+
setCurrentStep('customer');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!validateStep('delivery')) {
|
|
365
|
+
setCurrentStep('delivery');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!validateStep('payment')) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// If online payment selected, initiate Paystack payment first
|
|
373
|
+
if (formData.paymentMethod === 'online') {
|
|
374
|
+
if (!paystackPublicKey) {
|
|
375
|
+
onError?.(new Error('Paystack is not configured. Please contact support.'));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!paystack.isLoaded) {
|
|
380
|
+
onError?.(new Error('Payment system is loading. Please try again.'));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const amountInKobo = Math.round(cart!.pricing.total * 100); // Convert to kobo
|
|
385
|
+
|
|
386
|
+
// Initiate Paystack payment popup
|
|
387
|
+
// The onSuccess callback in the hook will handle the checkout completion
|
|
388
|
+
paystack.initializePayment({
|
|
389
|
+
email: formData.email || cart?.customerEmail || 'customer@example.com',
|
|
390
|
+
amount: amountInKobo,
|
|
391
|
+
currency: 'NGN',
|
|
392
|
+
metadata: {
|
|
393
|
+
cartId: cart?.id,
|
|
394
|
+
custom_fields: [
|
|
395
|
+
{
|
|
396
|
+
display_name: 'Customer Name',
|
|
397
|
+
variable_name: 'customer_name',
|
|
398
|
+
value: `${formData.firstName} ${formData.lastName}`,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
display_name: 'Phone Number',
|
|
402
|
+
variable_name: 'phone_number',
|
|
403
|
+
value: formData.phone,
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return; // Exit here - payment flow continues in Paystack callback
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// For COD payment, proceed directly with checkout
|
|
413
|
+
try {
|
|
414
|
+
const order = await checkout.mutateAsync({
|
|
415
|
+
firstName: formData.firstName,
|
|
416
|
+
lastName: formData.lastName,
|
|
417
|
+
email: formData.email,
|
|
418
|
+
phone: formData.phone,
|
|
419
|
+
address: formData.address,
|
|
420
|
+
city: formData.city,
|
|
421
|
+
deliveryZoneId: selectedDeliveryZoneId || formData.deliveryZoneId,
|
|
422
|
+
paymentMethod: formData.paymentMethod,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Check if response is an error
|
|
426
|
+
if ('error' in order) {
|
|
427
|
+
const errorObj = order.error as { message?: string };
|
|
428
|
+
onError?.(new Error(errorObj?.message || 'Checkout failed'));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Store the completed order to show success view
|
|
433
|
+
setCompletedOrder(order as Order);
|
|
434
|
+
onSuccess?.(order.id);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
onError?.(error as Error);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const stepTitles: Record<Step, string> = {
|
|
441
|
+
customer: 'Customer Information',
|
|
442
|
+
delivery: 'Delivery Address',
|
|
443
|
+
payment: 'Payment Method',
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const currentStepTitle = useMemo(() => stepTitles[currentStep], [currentStep]);
|
|
447
|
+
|
|
448
|
+
const showTotal = currentStep === 'payment' || currentStep === 'delivery';
|
|
449
|
+
|
|
450
|
+
const footerContent = (
|
|
451
|
+
<div className="space-y-3">
|
|
452
|
+
{/* Total Display */}
|
|
453
|
+
{showTotal && cart?.pricing?.total && (
|
|
454
|
+
<div className='text-sm text-black relative'>
|
|
455
|
+
{isLoadingCart && <div className='absolute flex items-center justify-center inset-0 bg-white opacity-50'>
|
|
456
|
+
<Loader2 className="animate-spin text-accent-500" />
|
|
457
|
+
</div>}
|
|
458
|
+
<div className="flex items-center justify-between font-medium">
|
|
459
|
+
<span>Subtotal</span>
|
|
460
|
+
<span>
|
|
461
|
+
{formatCurrency(cart?.pricing?.subtotal ?? 0)}
|
|
462
|
+
</span>
|
|
463
|
+
</div>
|
|
464
|
+
{cart.pricing.discount && <div className="flex items-center justify-between font-medium">
|
|
465
|
+
<span>Discount</span>
|
|
466
|
+
<span className="text-green-600">
|
|
467
|
+
-{formatCurrency(cart?.pricing?.discount?.amount ?? 0)}
|
|
468
|
+
</span>
|
|
469
|
+
</div>}
|
|
470
|
+
<div className="flex items-center justify-between font-medium">
|
|
471
|
+
<span>Delivery Fee</span>
|
|
472
|
+
<span>
|
|
473
|
+
{formatCurrency(cart?.pricing?.deliveryCharge ?? 0)}
|
|
474
|
+
</span>
|
|
475
|
+
</div>
|
|
476
|
+
<hr className="border-gray-200 my-2" />
|
|
477
|
+
<div className="flex items-center justify-between font-medium">
|
|
478
|
+
<span>Total</span>
|
|
479
|
+
<span>
|
|
480
|
+
{formatCurrency(cart?.pricing?.total ?? 0)}
|
|
481
|
+
</span>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
)}
|
|
485
|
+
|
|
486
|
+
{/* Navigation Buttons */}
|
|
487
|
+
<div className="flex gap-3">
|
|
488
|
+
{currentStep !== 'customer' && (
|
|
489
|
+
<Button
|
|
490
|
+
type="button"
|
|
491
|
+
variant="outline"
|
|
492
|
+
onClick={goToPreviousStep}
|
|
493
|
+
className="flex-1 border-gray-400"
|
|
494
|
+
size="lg"
|
|
495
|
+
>
|
|
496
|
+
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
497
|
+
Back
|
|
498
|
+
</Button>
|
|
499
|
+
)}
|
|
500
|
+
|
|
501
|
+
{currentStep !== 'payment' ? (
|
|
502
|
+
<Button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={goToNextStep}
|
|
505
|
+
className="flex-1 bg-accent-500 text-white hover:bg-accent-600"
|
|
506
|
+
size="lg"
|
|
507
|
+
>
|
|
508
|
+
Next
|
|
509
|
+
<ChevronRight className="ml-2 h-4 w-4" />
|
|
510
|
+
</Button>
|
|
511
|
+
) : (
|
|
512
|
+
<Button
|
|
513
|
+
type="button"
|
|
514
|
+
onClick={handleCheckout}
|
|
515
|
+
disabled={checkout.isPending}
|
|
516
|
+
className="flex-1 bg-accent-500 text-white hover:bg-accent-600"
|
|
517
|
+
size="lg"
|
|
518
|
+
>
|
|
519
|
+
{checkout.isPending ? (
|
|
520
|
+
<>
|
|
521
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
522
|
+
Processing...
|
|
523
|
+
</>
|
|
524
|
+
) : (
|
|
525
|
+
submitButtonText
|
|
526
|
+
)}
|
|
527
|
+
</Button>
|
|
528
|
+
)}
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
{/* Error Message */}
|
|
532
|
+
{checkout.isError && (
|
|
533
|
+
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
|
534
|
+
Failed to place order. Please check your information and try again.
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const isPaymentMethodDisabled = (paymentMethod: PaymentMethod) => !cart?.availablePaymentMethods.includes(paymentMethod);
|
|
541
|
+
|
|
542
|
+
// Show success view if checkout completed
|
|
543
|
+
if (completedOrder) {
|
|
544
|
+
return (
|
|
545
|
+
<Modal
|
|
546
|
+
isOpen={isOpen}
|
|
547
|
+
onClose={onClose}
|
|
548
|
+
title="Order Placed Successfully!"
|
|
549
|
+
footer={
|
|
550
|
+
<Button
|
|
551
|
+
onClick={onClose}
|
|
552
|
+
className="w-full bg-accent-500 text-white hover:bg-accent-600"
|
|
553
|
+
size="lg"
|
|
554
|
+
>
|
|
555
|
+
Close
|
|
556
|
+
</Button>
|
|
557
|
+
}
|
|
558
|
+
size="lg"
|
|
559
|
+
contentClassName="p-6"
|
|
560
|
+
>
|
|
561
|
+
<div className="text-center space-y-6">
|
|
562
|
+
{/* Success Icon */}
|
|
563
|
+
<div className="flex justify-center">
|
|
564
|
+
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center">
|
|
565
|
+
<CheckCircle className="h-12 w-12 text-green-600" />
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
{/* Success Message */}
|
|
570
|
+
<div className="space-y-2">
|
|
571
|
+
<h3 className="text-2xl font-bold text-gray-900">Thank You!</h3>
|
|
572
|
+
<p className="text-gray-600">
|
|
573
|
+
Your order has been placed successfully.
|
|
574
|
+
</p>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
{/* Order Details */}
|
|
578
|
+
<div className="bg-gray-50 rounded-lg p-6 space-y-4">
|
|
579
|
+
<div className="flex items-center justify-center space-x-2 text-gray-700">
|
|
580
|
+
<Package className="h-5 w-5" />
|
|
581
|
+
<span className="font-medium">Order #{completedOrder.orderNumber}</span>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<div className="border-t border-gray-200 pt-4 space-y-3">
|
|
585
|
+
<div className="flex justify-between text-sm">
|
|
586
|
+
<span className="text-gray-600">Customer</span>
|
|
587
|
+
<span className="font-medium text-gray-900">
|
|
588
|
+
{completedOrder.firstName} {completedOrder.lastName}
|
|
589
|
+
</span>
|
|
590
|
+
</div>
|
|
591
|
+
<div className="flex justify-between text-sm">
|
|
592
|
+
<span className="text-gray-600">Phone</span>
|
|
593
|
+
<span className="font-medium text-gray-900">{completedOrder.phone}</span>
|
|
594
|
+
</div>
|
|
595
|
+
{completedOrder.email && (
|
|
596
|
+
<div className="flex justify-between text-sm">
|
|
597
|
+
<span className="text-gray-600">Email</span>
|
|
598
|
+
<span className="font-medium text-gray-900">{completedOrder.email}</span>
|
|
599
|
+
</div>
|
|
600
|
+
)}
|
|
601
|
+
<div className="flex justify-between text-sm">
|
|
602
|
+
<span className="text-gray-600">Payment Method</span>
|
|
603
|
+
<span className="font-medium text-gray-900">
|
|
604
|
+
{completedOrder.paymentMethod === 'cod' ? 'Cash on Delivery' : 'Online Payment'}
|
|
605
|
+
</span>
|
|
606
|
+
</div>
|
|
607
|
+
<div className="flex justify-between text-sm">
|
|
608
|
+
<span className="text-gray-600">Total</span>
|
|
609
|
+
<span className="font-bold text-gray-900">
|
|
610
|
+
{formatCurrency(completedOrder.totalPrice)}
|
|
611
|
+
</span>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{/* Delivery Notice */}
|
|
617
|
+
<div className="bg-blue-50 rounded-lg p-4">
|
|
618
|
+
<p className="text-sm text-blue-900">
|
|
619
|
+
<span className="font-semibold">We'll be in touch shortly!</span>
|
|
620
|
+
<br />
|
|
621
|
+
Our team will contact you soon to arrange delivery and confirm your order details.
|
|
622
|
+
</p>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
</Modal>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<Modal
|
|
631
|
+
isOpen={isOpen}
|
|
632
|
+
onClose={onClose}
|
|
633
|
+
title={currentStepTitle}
|
|
634
|
+
footer={footerContent}
|
|
635
|
+
size="lg"
|
|
636
|
+
contentClassName="p-0"
|
|
637
|
+
>
|
|
638
|
+
{isLoadingCart ? (
|
|
639
|
+
<div className="flex items-center justify-center min-h-[300px]">
|
|
640
|
+
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
|
|
641
|
+
</div>
|
|
642
|
+
) : (
|
|
643
|
+
<div className="w-full overflow-hidden">
|
|
644
|
+
{/* Sliding Steps Container */}
|
|
645
|
+
<div className="relative w-full">
|
|
646
|
+
<div
|
|
647
|
+
className={cn(
|
|
648
|
+
'flex transition-transform duration-300 ease-in-out w-full',
|
|
649
|
+
currentStep === 'customer' && 'translate-x-0',
|
|
650
|
+
currentStep === 'delivery' && '-translate-x-full',
|
|
651
|
+
currentStep === 'payment' && '-translate-x-[200%]'
|
|
652
|
+
)}
|
|
653
|
+
>
|
|
654
|
+
{/* Step 1: Customer Information */}
|
|
655
|
+
<div className="w-full min-w-full flex-shrink-0 p-4">
|
|
656
|
+
<div className="space-y-4">
|
|
657
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
658
|
+
<FormInput
|
|
659
|
+
label="First Name"
|
|
660
|
+
type="text"
|
|
661
|
+
name="firstName"
|
|
662
|
+
value={formData.firstName}
|
|
663
|
+
onChange={handleInputChange}
|
|
664
|
+
onBlur={() => handleCustomerFieldBlur('firstName')}
|
|
665
|
+
error={formErrors.firstName}
|
|
666
|
+
autoCorrect="off"
|
|
667
|
+
required
|
|
668
|
+
/>
|
|
669
|
+
<FormInput
|
|
670
|
+
label="Last Name"
|
|
671
|
+
type="text"
|
|
672
|
+
name="lastName"
|
|
673
|
+
value={formData.lastName}
|
|
674
|
+
onChange={handleInputChange}
|
|
675
|
+
onBlur={() => handleCustomerFieldBlur('lastName')}
|
|
676
|
+
error={formErrors.lastName}
|
|
677
|
+
autoCorrect="off"
|
|
678
|
+
required
|
|
679
|
+
/>
|
|
680
|
+
<FormInput
|
|
681
|
+
label="Email"
|
|
682
|
+
type="email"
|
|
683
|
+
name="email"
|
|
684
|
+
value={formData.email}
|
|
685
|
+
onChange={handleInputChange}
|
|
686
|
+
onBlur={() => handleCustomerFieldBlur('email')}
|
|
687
|
+
error={formErrors.email}
|
|
688
|
+
icon={<Mail size={16} />}
|
|
689
|
+
autoCorrect="off"
|
|
690
|
+
required
|
|
691
|
+
/>
|
|
692
|
+
<FormInput
|
|
693
|
+
label="Phone Number"
|
|
694
|
+
type="tel"
|
|
695
|
+
name="phone"
|
|
696
|
+
value={formData.phone}
|
|
697
|
+
onChange={handleInputChange}
|
|
698
|
+
onBlur={() => handleCustomerFieldBlur('phone')}
|
|
699
|
+
placeholder="e.g. 08012345678"
|
|
700
|
+
maxLength={18}
|
|
701
|
+
error={formErrors.phone}
|
|
702
|
+
icon={<Phone size={16} />}
|
|
703
|
+
autoCorrect="off"
|
|
704
|
+
required
|
|
705
|
+
className="font-semibold"
|
|
706
|
+
/>
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
|
|
711
|
+
{/* Step 2: Delivery Address */}
|
|
712
|
+
<div className="w-full min-w-full flex-shrink-0 p-4">
|
|
713
|
+
<div className="space-y-4">
|
|
714
|
+
<FormInput
|
|
715
|
+
label="Street Address"
|
|
716
|
+
type="text"
|
|
717
|
+
name="address"
|
|
718
|
+
value={formData.address}
|
|
719
|
+
onChange={handleInputChange}
|
|
720
|
+
error={formErrors.address}
|
|
721
|
+
autoCorrect="off"
|
|
722
|
+
required
|
|
723
|
+
/>
|
|
724
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
725
|
+
<FormInput
|
|
726
|
+
label="Area"
|
|
727
|
+
type="text"
|
|
728
|
+
name="city"
|
|
729
|
+
value={formData.city}
|
|
730
|
+
onChange={handleInputChange}
|
|
731
|
+
error={formErrors.city}
|
|
732
|
+
autoCorrect="off"
|
|
733
|
+
required
|
|
734
|
+
/>
|
|
735
|
+
<FormSelect
|
|
736
|
+
label="State"
|
|
737
|
+
name="state"
|
|
738
|
+
value={selectedStateId || ""}
|
|
739
|
+
onChange={(e) => {
|
|
740
|
+
setSelectedDeliveryZoneId('');
|
|
741
|
+
const value = e.target.value;
|
|
742
|
+
setSelectedStateId(value || undefined);
|
|
743
|
+
// Clear error when user selects
|
|
744
|
+
if (formErrors.state) {
|
|
745
|
+
setFormErrors((prev) => ({ ...prev, state: undefined }));
|
|
746
|
+
}
|
|
747
|
+
}}
|
|
748
|
+
error={formErrors.state}
|
|
749
|
+
required
|
|
750
|
+
>
|
|
751
|
+
<option disabled value="">Select State</option>
|
|
752
|
+
{states?.map((state) => (
|
|
753
|
+
<option key={state.id} value={state.id}>
|
|
754
|
+
{state.name}
|
|
755
|
+
</option>
|
|
756
|
+
))}
|
|
757
|
+
</FormSelect>
|
|
758
|
+
|
|
759
|
+
<FormSelect
|
|
760
|
+
label="Delivery Zone"
|
|
761
|
+
value={selectedDeliveryZoneId || ""}
|
|
762
|
+
onChange={(e) => {
|
|
763
|
+
const zoneId = e.target.value;
|
|
764
|
+
setSelectedDeliveryZoneId(zoneId || undefined);
|
|
765
|
+
// Clear error when user selects
|
|
766
|
+
if (formErrors.deliveryZoneId) {
|
|
767
|
+
setFormErrors((prev) => ({ ...prev, deliveryZoneId: undefined }));
|
|
768
|
+
}
|
|
769
|
+
// Auto-update cart with delivery zone
|
|
770
|
+
if (zoneId) {
|
|
771
|
+
updateCartMutation.mutate({ deliveryZoneId: zoneId });
|
|
772
|
+
}
|
|
773
|
+
}}
|
|
774
|
+
disabled={!selectedState}
|
|
775
|
+
error={formErrors.deliveryZoneId}
|
|
776
|
+
required
|
|
777
|
+
>
|
|
778
|
+
<option disabled value="">{selectedState ? 'Select Delivery Zone' : 'Select State First'}</option>
|
|
779
|
+
{selectedState?.zones.map((zone) => (
|
|
780
|
+
<option key={zone.id} value={zone.id}>
|
|
781
|
+
{zone.name} ({formatCurrency(zone.deliveryCost)})
|
|
782
|
+
</option>
|
|
783
|
+
))}
|
|
784
|
+
</FormSelect>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
{/* Step 3: Payment Method */}
|
|
790
|
+
<div className="w-full min-w-full flex-shrink-0 p-4">
|
|
791
|
+
<div className="space-y-6">
|
|
792
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
793
|
+
{/* Online Payment */}
|
|
794
|
+
<div className="relative">
|
|
795
|
+
<label
|
|
796
|
+
className={cn(
|
|
797
|
+
'flex items-center justify-center relative p-4 border-2 rounded-xl cursor-pointer transition-all duration-200',
|
|
798
|
+
formData.paymentMethod === 'online'
|
|
799
|
+
? 'border-accent-500 bg-accent-50'
|
|
800
|
+
: 'border-gray-200 hover:border-gray-300',
|
|
801
|
+
isPaymentMethodDisabled('online') && 'opacity-50 cursor-not-allowed'
|
|
802
|
+
)}
|
|
803
|
+
>
|
|
804
|
+
<input
|
|
805
|
+
type="radio"
|
|
806
|
+
name="paymentMethod"
|
|
807
|
+
value="online"
|
|
808
|
+
disabled={isPaymentMethodDisabled('online')}
|
|
809
|
+
checked={formData.paymentMethod === 'online'}
|
|
810
|
+
onChange={handleInputChange}
|
|
811
|
+
className="sr-only"
|
|
812
|
+
/>
|
|
813
|
+
<div className="flex flex-col items-center text-center space-y-3">
|
|
814
|
+
<div
|
|
815
|
+
className={cn(
|
|
816
|
+
'p-3 rounded-full',
|
|
817
|
+
formData.paymentMethod === 'online'
|
|
818
|
+
? 'bg-accent-100 text-accent-600'
|
|
819
|
+
: 'bg-gray-100 text-gray-600'
|
|
820
|
+
)}
|
|
821
|
+
>
|
|
822
|
+
<Zap size={24} />
|
|
823
|
+
</div>
|
|
824
|
+
<div>
|
|
825
|
+
<div className="font-semibold text-gray-900">Pay Online</div>
|
|
826
|
+
<div className="text-sm text-gray-500 mt-1">
|
|
827
|
+
Bank transfer
|
|
828
|
+
</div>
|
|
829
|
+
<img
|
|
830
|
+
src={PaystackSVG}
|
|
831
|
+
alt="Paystack"
|
|
832
|
+
className="h-4 w-auto mt-2 mx-auto"
|
|
833
|
+
/>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
{formData.paymentMethod === 'online' && (
|
|
837
|
+
<div className="absolute top-2 right-2">
|
|
838
|
+
<div className="w-5 h-5 bg-accent-600 rounded-full flex items-center justify-center">
|
|
839
|
+
<div className="w-2 h-2 bg-white rounded-full"></div>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
843
|
+
</label>
|
|
844
|
+
</div>
|
|
845
|
+
|
|
846
|
+
{/* Cash on Delivery */}
|
|
847
|
+
<label
|
|
848
|
+
className={cn(
|
|
849
|
+
'flex items-center justify-center relative p-4 border-2 rounded-xl cursor-pointer transition-all duration-200',
|
|
850
|
+
formData.paymentMethod === 'cod'
|
|
851
|
+
? 'border-accent-500 bg-accent-50'
|
|
852
|
+
: 'border-gray-200 hover:border-gray-300',
|
|
853
|
+
isPaymentMethodDisabled('cod') && 'opacity-50 cursor-not-allowed'
|
|
854
|
+
)}
|
|
855
|
+
>
|
|
856
|
+
<input
|
|
857
|
+
type="radio"
|
|
858
|
+
name="paymentMethod"
|
|
859
|
+
value="cod"
|
|
860
|
+
disabled={isPaymentMethodDisabled('cod')}
|
|
861
|
+
checked={formData.paymentMethod === 'cod'}
|
|
862
|
+
onChange={handleInputChange}
|
|
863
|
+
className="sr-only"
|
|
864
|
+
/>
|
|
865
|
+
<div className="flex flex-col items-center text-center space-y-3">
|
|
866
|
+
<div
|
|
867
|
+
className={cn(
|
|
868
|
+
'p-3 rounded-full',
|
|
869
|
+
formData.paymentMethod === 'cod'
|
|
870
|
+
? 'bg-accent-100 text-accent-600'
|
|
871
|
+
: 'bg-gray-100 text-gray-600'
|
|
872
|
+
)}
|
|
873
|
+
>
|
|
874
|
+
<Banknote size={24} />
|
|
875
|
+
</div>
|
|
876
|
+
<div>
|
|
877
|
+
<div className="font-semibold text-gray-900">Pay on Delivery</div>
|
|
878
|
+
<div className="text-sm text-gray-500 mt-1">
|
|
879
|
+
Cash when item arrives
|
|
880
|
+
</div>
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
{formData.paymentMethod === 'cod' && (
|
|
884
|
+
<div className="absolute top-2 right-2">
|
|
885
|
+
<div className="w-5 h-5 bg-accent-600 rounded-full flex items-center justify-center">
|
|
886
|
+
<div className="w-2 h-2 bg-white rounded-full"></div>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
</label>
|
|
891
|
+
</div>
|
|
892
|
+
|
|
893
|
+
{formErrors.paymentMethod && (
|
|
894
|
+
<p className="text-red-600 text-sm">{formErrors.paymentMethod}</p>
|
|
895
|
+
)}
|
|
896
|
+
|
|
897
|
+
{/* Refund Policy Accordion */}
|
|
898
|
+
{refundPolicy && (
|
|
899
|
+
<div className="border border-gray-200 rounded-lg">
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
onClick={() => setIsRefundAccordionOpen(!isRefundAccordionOpen)}
|
|
903
|
+
className="overflow-hidden w-full flex items-center overflow-hidden rounded-lg rounded-b-none justify-between p-4 text-left hover:bg-gray-50 transition-colors"
|
|
904
|
+
>
|
|
905
|
+
<span className="text-sm font-medium text-gray-700">
|
|
906
|
+
{refundPolicy.title || '🤔 Curious about Refunds?'}
|
|
907
|
+
</span>
|
|
908
|
+
<ChevronDown
|
|
909
|
+
size={16}
|
|
910
|
+
className={cn(
|
|
911
|
+
'transform transition-transform duration-200 text-gray-500',
|
|
912
|
+
isRefundAccordionOpen && 'rotate-180'
|
|
913
|
+
)}
|
|
914
|
+
/>
|
|
915
|
+
</button>
|
|
916
|
+
<div
|
|
917
|
+
className={cn(
|
|
918
|
+
'overflow-hidden transition-all duration-300 ease-in-out',
|
|
919
|
+
isRefundAccordionOpen
|
|
920
|
+
? 'max-h-96 opacity-100'
|
|
921
|
+
: 'max-h-0 opacity-0'
|
|
922
|
+
)}
|
|
923
|
+
>
|
|
924
|
+
<div className="px-4 pb-4 border-t border-gray-100">
|
|
925
|
+
<div className="text-sm text-gray-600 space-y-3 pt-4">
|
|
926
|
+
{refundPolicy.policies && (
|
|
927
|
+
<div>
|
|
928
|
+
<h4 className="font-bold text-gray-800 mb-2">
|
|
929
|
+
Our Refund Policy
|
|
930
|
+
</h4>
|
|
931
|
+
<ul className="space-y-1 ml-4">
|
|
932
|
+
{refundPolicy.policies.map((policy, idx) => (
|
|
933
|
+
<li key={idx} className="flex items-start">
|
|
934
|
+
<span className="text-green-600 mr-2">✓</span>
|
|
935
|
+
<span>{policy}</span>
|
|
936
|
+
</li>
|
|
937
|
+
))}
|
|
938
|
+
</ul>
|
|
939
|
+
</div>
|
|
940
|
+
)}
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
)}
|
|
952
|
+
</Modal>
|
|
953
|
+
);
|
|
954
|
+
}
|