@instockng/storefront-ui 1.0.9 → 1.0.11

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 (104) hide show
  1. package/README.md +26 -0
  2. package/dist/components/Checkout.d.ts.map +1 -1
  3. package/dist/contexts/CartContext.d.ts.map +1 -1
  4. package/dist/index10.mjs +144 -141
  5. package/dist/index101.mjs +1 -1
  6. package/dist/index102.mjs +3 -3
  7. package/dist/index103.mjs +3 -3
  8. package/dist/index105.mjs +1 -1
  9. package/dist/index111.mjs +1 -1
  10. package/dist/index20.mjs +2 -2
  11. package/dist/index21.mjs +1 -1
  12. package/dist/index28.mjs +11 -11
  13. package/dist/index29.mjs +1 -1
  14. package/dist/index3.mjs +88 -74
  15. package/dist/index30.mjs +1 -1
  16. package/dist/index31.mjs +1 -1
  17. package/dist/index32.mjs +1 -1
  18. package/dist/index37.mjs +1 -1
  19. package/dist/index41.mjs +36 -23
  20. package/dist/index42.mjs +44 -36
  21. package/dist/index43.mjs +99 -44
  22. package/dist/index44.mjs +112 -99
  23. package/dist/index45.mjs +44 -80
  24. package/dist/index46.mjs +64 -53
  25. package/dist/index47.mjs +66 -49
  26. package/dist/index48.mjs +54 -73
  27. package/dist/index49.mjs +52 -63
  28. package/dist/index50.mjs +14 -70
  29. package/dist/index51.mjs +13 -14
  30. package/dist/index52.mjs +58 -13
  31. package/dist/index53.mjs +101 -34
  32. package/dist/index54.mjs +99 -95
  33. package/dist/index55.mjs +22 -132
  34. package/dist/index58.mjs +2 -2
  35. package/dist/index59.mjs +4 -3
  36. package/dist/index60.mjs +4 -2
  37. package/dist/index61.mjs +2 -5
  38. package/dist/index62.mjs +30 -231
  39. package/dist/index63.mjs +42 -5
  40. package/dist/index64.mjs +228 -127
  41. package/dist/index65.mjs +4 -66
  42. package/dist/index66.mjs +124 -77
  43. package/dist/index67.mjs +65 -26
  44. package/dist/index68.mjs +84 -6
  45. package/dist/index69.mjs +26 -72
  46. package/dist/index70.mjs +8 -3
  47. package/dist/index71.mjs +75 -2
  48. package/dist/index72.mjs +3 -82
  49. package/dist/index73.mjs +2 -54
  50. package/dist/index74.mjs +82 -5
  51. package/dist/index75.mjs +53 -4
  52. package/dist/index76.mjs +5 -178
  53. package/dist/index77.mjs +5 -53
  54. package/dist/index78.mjs +178 -68
  55. package/dist/index79.mjs +50 -31
  56. package/dist/index80.mjs +69 -43
  57. package/dist/index81.mjs +2 -2
  58. package/dist/index82.mjs +1 -1
  59. package/dist/index83.mjs +6 -6
  60. package/dist/index84.mjs +2 -2
  61. package/dist/index85.mjs +2 -2
  62. package/dist/index87.mjs +2 -2
  63. package/dist/index88.mjs +2 -2
  64. package/dist/index89.mjs +1 -1
  65. package/dist/index91.mjs +4 -4
  66. package/dist/index92.mjs +3 -3
  67. package/dist/index93.mjs +30 -12
  68. package/dist/index94.mjs +11 -7
  69. package/dist/index95.mjs +3 -30
  70. package/dist/index96.mjs +3 -10
  71. package/dist/index97.mjs +13 -4
  72. package/dist/index98.mjs +7 -4
  73. package/dist/index99.mjs +1 -1
  74. package/dist/styles.css +1 -0
  75. package/package.json +14 -13
  76. package/src/components/CartItem.stories.tsx +94 -0
  77. package/src/components/CartItem.tsx +141 -0
  78. package/src/components/Checkout.stories.tsx +380 -0
  79. package/src/components/Checkout.tsx +954 -0
  80. package/src/components/DiscountCodeInput.stories.tsx +76 -0
  81. package/src/components/DiscountCodeInput.tsx +162 -0
  82. package/src/components/OrderConfirmation.stories.tsx +142 -0
  83. package/src/components/OrderConfirmation.tsx +301 -0
  84. package/src/components/ProductCard.stories.tsx +112 -0
  85. package/src/components/ProductCard.tsx +195 -0
  86. package/src/components/ProductGrid.stories.tsx +137 -0
  87. package/src/components/ProductGrid.tsx +141 -0
  88. package/src/components/ShoppingCart.stories.tsx +459 -0
  89. package/src/components/ShoppingCart.tsx +262 -0
  90. package/src/components/ui/badge.tsx +37 -0
  91. package/src/components/ui/button.tsx +71 -0
  92. package/src/components/ui/card.tsx +79 -0
  93. package/src/components/ui/form-input.tsx +78 -0
  94. package/src/components/ui/form-select.tsx +73 -0
  95. package/src/components/ui/modal.tsx +181 -0
  96. package/src/contexts/CartContext.tsx +305 -0
  97. package/src/hooks/usePaystackPayment.ts +137 -0
  98. package/src/index.ts +51 -0
  99. package/src/lib/utils.ts +45 -0
  100. package/src/paystack.svg +67 -0
  101. package/src/providers/StorefrontProvider.tsx +70 -0
  102. package/src/styles.css +1 -0
  103. package/src/test-utils/MockCartProvider.tsx +424 -0
  104. 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
+ }