@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.
- package/README.md +26 -0
- package/dist/components/Checkout.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/index29.mjs +1 -1
- package/dist/index3.mjs +88 -74
- package/dist/index30.mjs +1 -1
- package/dist/index31.mjs +1 -1
- package/dist/index32.mjs +1 -1
- 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 +66 -49
- 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/index58.mjs +2 -2
- package/dist/index59.mjs +4 -3
- package/dist/index60.mjs +4 -2
- package/dist/index61.mjs +2 -5
- 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/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 +30 -12
- package/dist/index94.mjs +11 -7
- package/dist/index95.mjs +3 -30
- package/dist/index96.mjs +3 -10
- package/dist/index97.mjs +13 -4
- package/dist/index98.mjs +7 -4
- 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 +262 -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 +305 -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,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal Component
|
|
5
|
+
*
|
|
6
|
+
* A floating modal dialog with overlay, header, content area, and footer.
|
|
7
|
+
* Built with React portals for proper z-index stacking.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
12
|
+
import { X } from 'lucide-react';
|
|
13
|
+
import { cn } from '../../lib/utils';
|
|
14
|
+
|
|
15
|
+
export interface ModalProps {
|
|
16
|
+
/** Controls modal visibility */
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
/** Callback when modal should close */
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
/** Modal title - can be string or React element */
|
|
21
|
+
title?: React.ReactNode;
|
|
22
|
+
/** Modal content */
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
/** Footer content (typically buttons) */
|
|
25
|
+
footer?: React.ReactNode;
|
|
26
|
+
/** Additional CSS class for the modal container */
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Additional CSS class for the modal content area */
|
|
29
|
+
contentClassName?: string;
|
|
30
|
+
/** Size of the modal */
|
|
31
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
32
|
+
/** Whether clicking the overlay closes the modal */
|
|
33
|
+
closeOnOverlayClick?: boolean;
|
|
34
|
+
/** Whether to show the close button */
|
|
35
|
+
showCloseButton?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Modal({
|
|
39
|
+
isOpen,
|
|
40
|
+
onClose,
|
|
41
|
+
title,
|
|
42
|
+
children,
|
|
43
|
+
footer,
|
|
44
|
+
className,
|
|
45
|
+
contentClassName,
|
|
46
|
+
size = 'md',
|
|
47
|
+
closeOnOverlayClick = true,
|
|
48
|
+
showCloseButton = true,
|
|
49
|
+
}: ModalProps): JSX.Element | null {
|
|
50
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
52
|
+
const [shouldRender, setShouldRender] = useState(isOpen);
|
|
53
|
+
|
|
54
|
+
// Handle animation state
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (isOpen) {
|
|
57
|
+
setShouldRender(true);
|
|
58
|
+
// Small delay to trigger animation
|
|
59
|
+
requestAnimationFrame(() => {
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
setIsAnimating(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
setIsAnimating(false);
|
|
66
|
+
// Wait for animation to finish before unmounting
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
setShouldRender(false);
|
|
69
|
+
}, 200); // Match transition duration
|
|
70
|
+
return () => clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}, [isOpen]);
|
|
73
|
+
|
|
74
|
+
// Handle ESC key
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const handleEsc = (e: KeyboardEvent) => {
|
|
77
|
+
if (e.key === 'Escape' && isOpen) {
|
|
78
|
+
onClose();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (isOpen) {
|
|
83
|
+
document.addEventListener('keydown', handleEsc);
|
|
84
|
+
return () => document.removeEventListener('keydown', handleEsc);
|
|
85
|
+
}
|
|
86
|
+
}, [isOpen, onClose]);
|
|
87
|
+
|
|
88
|
+
// Lock body scroll when modal is open
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (isOpen) {
|
|
91
|
+
const originalOverflow = document.body.style.overflow;
|
|
92
|
+
document.body.style.overflow = 'hidden';
|
|
93
|
+
return () => {
|
|
94
|
+
document.body.style.overflow = originalOverflow;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}, [isOpen]);
|
|
98
|
+
|
|
99
|
+
if (!shouldRender) return null;
|
|
100
|
+
|
|
101
|
+
const sizeClasses = {
|
|
102
|
+
sm: 'max-w-md',
|
|
103
|
+
md: 'max-w-lg',
|
|
104
|
+
lg: 'max-w-2xl',
|
|
105
|
+
xl: 'max-w-4xl',
|
|
106
|
+
full: 'max-w-full mx-4',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleOverlayClick = (e: React.MouseEvent) => {
|
|
110
|
+
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
111
|
+
onClose();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const modalContent = (
|
|
116
|
+
<div
|
|
117
|
+
className={cn(
|
|
118
|
+
'fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4 bg-black/50 backdrop-blur-sm transition-opacity duration-200',
|
|
119
|
+
isAnimating ? 'opacity-100' : 'opacity-0'
|
|
120
|
+
)}
|
|
121
|
+
onClick={handleOverlayClick}
|
|
122
|
+
>
|
|
123
|
+
<div
|
|
124
|
+
ref={modalRef}
|
|
125
|
+
className={cn(
|
|
126
|
+
'relative w-full bg-white rounded-xl shadow-xl flex flex-col max-h-[90vh] transition-all duration-200',
|
|
127
|
+
isAnimating ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
|
128
|
+
sizeClasses[size],
|
|
129
|
+
className
|
|
130
|
+
)}
|
|
131
|
+
role="dialog"
|
|
132
|
+
aria-modal="true"
|
|
133
|
+
aria-labelledby={title ? 'modal-title' : undefined}
|
|
134
|
+
>
|
|
135
|
+
{/* Header */}
|
|
136
|
+
{(title || showCloseButton) && (
|
|
137
|
+
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0">
|
|
138
|
+
{title && (
|
|
139
|
+
<h2
|
|
140
|
+
id="modal-title"
|
|
141
|
+
className="text-xl font-semibold text-gray-900"
|
|
142
|
+
>
|
|
143
|
+
{title}
|
|
144
|
+
</h2>
|
|
145
|
+
)}
|
|
146
|
+
{showCloseButton && (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={onClose}
|
|
150
|
+
className="ml-auto p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
151
|
+
aria-label="Close modal"
|
|
152
|
+
>
|
|
153
|
+
<X size={20} />
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Content */}
|
|
160
|
+
<div
|
|
161
|
+
className={cn(
|
|
162
|
+
'flex-1 overflow-y-auto p-4 min-h-0',
|
|
163
|
+
contentClassName
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Footer */}
|
|
170
|
+
{footer && (
|
|
171
|
+
<div className="p-4 border-t border-gray-200 bg-gray-50 flex-shrink-0 rounded-b-xl">
|
|
172
|
+
{footer}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Render modal using portal
|
|
180
|
+
return createPortal(modalContent, document.body);
|
|
181
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cart Context Provider
|
|
5
|
+
*
|
|
6
|
+
* Manages cart state with localStorage persistence and auto-creation.
|
|
7
|
+
* Uses @oms/api-client hooks for all cart operations.
|
|
8
|
+
* Includes built-in ShoppingCart modal that can be opened/closed from anywhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createContext, useContext, useState, useEffect, ReactNode, useCallback, useRef } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
useGetCart,
|
|
14
|
+
useCreateCart,
|
|
15
|
+
useUpdateCart,
|
|
16
|
+
useAddCartItem,
|
|
17
|
+
useUpdateCartItem,
|
|
18
|
+
useRemoveCartItem,
|
|
19
|
+
useApplyDiscount,
|
|
20
|
+
useRemoveDiscount,
|
|
21
|
+
useCheckoutCart,
|
|
22
|
+
type Cart,
|
|
23
|
+
} from '@oms/api-client';
|
|
24
|
+
import { ShoppingCart } from '../components/ShoppingCart';
|
|
25
|
+
|
|
26
|
+
const CART_ID_KEY = 'oms_cart_id';
|
|
27
|
+
|
|
28
|
+
interface CartContextValue {
|
|
29
|
+
/** Current cart data */
|
|
30
|
+
cart: Cart | null;
|
|
31
|
+
/** Cart ID (from localStorage or newly created) */
|
|
32
|
+
cartId: string | null;
|
|
33
|
+
/** Loading state */
|
|
34
|
+
isLoading: boolean;
|
|
35
|
+
/** Error state */
|
|
36
|
+
error: Error | null;
|
|
37
|
+
/** Update cart mutation (from useUpdateCart) */
|
|
38
|
+
updateCartMutation: ReturnType<typeof useUpdateCart>;
|
|
39
|
+
/** Checkout mutation (from useCheckoutCart) */
|
|
40
|
+
checkoutMutation: ReturnType<typeof useCheckoutCart>;
|
|
41
|
+
/** Add item to cart by SKU */
|
|
42
|
+
addItem: (sku: string, quantity: number) => Promise<void>;
|
|
43
|
+
/** Update item quantity */
|
|
44
|
+
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
|
45
|
+
/** Remove item from cart */
|
|
46
|
+
removeItem: (itemId: string) => Promise<void>;
|
|
47
|
+
/** Apply discount code */
|
|
48
|
+
applyDiscount: (code: string) => Promise<void>;
|
|
49
|
+
/** Remove discount code */
|
|
50
|
+
removeDiscount: () => Promise<void>;
|
|
51
|
+
/** Clear cart (removes from localStorage) */
|
|
52
|
+
clearCart: () => void;
|
|
53
|
+
/** Refresh cart data */
|
|
54
|
+
refetch: () => void;
|
|
55
|
+
/** Whether the cart modal is open */
|
|
56
|
+
isOpen: boolean;
|
|
57
|
+
/** Open the cart modal */
|
|
58
|
+
open: () => void;
|
|
59
|
+
/** Close the cart modal */
|
|
60
|
+
close: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const CartContext = createContext<CartContextValue | null>(null);
|
|
64
|
+
|
|
65
|
+
export interface CartProviderProps {
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
/** Brand slug for cart operations */
|
|
68
|
+
brandSlug: string;
|
|
69
|
+
/** Optional initial cart ID (overrides localStorage) */
|
|
70
|
+
initialCartId?: string;
|
|
71
|
+
/** Props to pass to the ShoppingCart component */
|
|
72
|
+
shoppingCartProps?: Omit<import('../components/ShoppingCart').ShoppingCartProps, 'isOpen' | 'onClose'>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function CartProvider({ children, brandSlug, initialCartId, shoppingCartProps }: CartProviderProps) {
|
|
76
|
+
// Always start with null to avoid hydration mismatch
|
|
77
|
+
// We'll load from localStorage in useEffect after mount
|
|
78
|
+
const [cartId, setCartId] = useState<string | null>(initialCartId || null);
|
|
79
|
+
|
|
80
|
+
// Cart modal state
|
|
81
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
82
|
+
|
|
83
|
+
// Track if component has mounted (client-side only)
|
|
84
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Use refs to prevent infinite loops
|
|
87
|
+
const hasInitializedRef = useRef(false);
|
|
88
|
+
const isHandlingErrorRef = useRef(false);
|
|
89
|
+
|
|
90
|
+
// Set mounted flag on client
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
setIsMounted(true);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// Load cartId from URL/localStorage after mount (client-side only)
|
|
96
|
+
// This avoids hydration mismatch between server and client
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (initialCartId || hasInitializedRef.current) return;
|
|
99
|
+
|
|
100
|
+
// Check URL query params first
|
|
101
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
102
|
+
const cartIdFromUrl = urlParams.get('cartId');
|
|
103
|
+
if (cartIdFromUrl) {
|
|
104
|
+
setCartId(cartIdFromUrl);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fall back to localStorage
|
|
109
|
+
const storedCartId = localStorage.getItem(CART_ID_KEY);
|
|
110
|
+
if (storedCartId) {
|
|
111
|
+
setCartId(storedCartId);
|
|
112
|
+
}
|
|
113
|
+
}, [initialCartId]);
|
|
114
|
+
|
|
115
|
+
// Fetch cart if we have a cartId
|
|
116
|
+
const { data: cart, isLoading, error, refetch } = useGetCart(cartId || '', {
|
|
117
|
+
enabled: !!cartId,
|
|
118
|
+
retry: false, // Don't retry on failure - cart might be invalid/expired
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Auto-create cart if none exists
|
|
122
|
+
const createCartMutation = useCreateCart({
|
|
123
|
+
onSuccess: (data) => {
|
|
124
|
+
// Check if response is an error
|
|
125
|
+
if ('error' in data) {
|
|
126
|
+
console.error('Failed to create cart:', data.error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setCartId(data.id);
|
|
131
|
+
},
|
|
132
|
+
onError: (error) => {
|
|
133
|
+
console.error('Failed to create cart:', error);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Auto-create cart on mount if no cartId exists - only runs ONCE
|
|
138
|
+
// Use a layout effect to run before paint
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!hasInitializedRef.current && !cartId && !initialCartId) {
|
|
141
|
+
hasInitializedRef.current = true;
|
|
142
|
+
// Defer cart creation to next tick
|
|
143
|
+
const timeoutId = setTimeout(() => {
|
|
144
|
+
if (!hasInitializedRef.current) return; // Double check
|
|
145
|
+
createCartMutation.mutate(brandSlug);
|
|
146
|
+
}, 0);
|
|
147
|
+
|
|
148
|
+
return () => clearTimeout(timeoutId);
|
|
149
|
+
}
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
// Handle cart fetch error (invalid/expired cart from URL or localStorage)
|
|
154
|
+
// Only clear the cart if there's an error, don't try to create a new one
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (error && cartId && !isHandlingErrorRef.current) {
|
|
157
|
+
isHandlingErrorRef.current = true;
|
|
158
|
+
console.warn('Cart fetch failed, clearing invalid cart:', error);
|
|
159
|
+
|
|
160
|
+
// Use a microtask to batch the state updates
|
|
161
|
+
Promise.resolve().then(() => {
|
|
162
|
+
setCartId(null);
|
|
163
|
+
if (typeof window !== 'undefined') {
|
|
164
|
+
localStorage.removeItem(CART_ID_KEY);
|
|
165
|
+
}
|
|
166
|
+
isHandlingErrorRef.current = false;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}, [error, cartId]);
|
|
170
|
+
|
|
171
|
+
// Mutations
|
|
172
|
+
const addItemMutation = useAddCartItem(cartId || '', {
|
|
173
|
+
onSuccess: () => refetch(),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const updateItemMutation = useUpdateCartItem(cartId || '', {
|
|
177
|
+
onSuccess: () => refetch(),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const removeItemMutation = useRemoveCartItem(cartId || '', {
|
|
181
|
+
onSuccess: () => refetch(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const applyDiscountMutation = useApplyDiscount(cartId || '', {
|
|
185
|
+
onSuccess: () => refetch(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const removeDiscountMutation = useRemoveDiscount(cartId || '', {
|
|
189
|
+
onSuccess: () => refetch(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const updateCartMutation = useUpdateCart(cartId || '', {
|
|
193
|
+
onSuccess: () => refetch(),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const checkoutMutation = useCheckoutCart(cartId || '');
|
|
197
|
+
|
|
198
|
+
// Store cartId in localStorage when it changes
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (cartId && typeof window !== 'undefined') {
|
|
201
|
+
localStorage.setItem(CART_ID_KEY, cartId);
|
|
202
|
+
}
|
|
203
|
+
}, [cartId]);
|
|
204
|
+
|
|
205
|
+
const addItem = useCallback(
|
|
206
|
+
async (sku: string, quantity: number) => {
|
|
207
|
+
if (!cartId) throw new Error('No cart ID');
|
|
208
|
+
await addItemMutation.mutateAsync({ sku, quantity });
|
|
209
|
+
},
|
|
210
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
|
+
[cartId]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const updateItem = useCallback(
|
|
215
|
+
async (itemId: string, quantity: number) => {
|
|
216
|
+
if (!cartId) throw new Error('No cart ID');
|
|
217
|
+
await updateItemMutation.mutateAsync({ itemId, quantity });
|
|
218
|
+
},
|
|
219
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
220
|
+
[cartId]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const removeItem = useCallback(
|
|
224
|
+
async (itemId: string) => {
|
|
225
|
+
if (!cartId) throw new Error('No cart ID');
|
|
226
|
+
await removeItemMutation.mutateAsync(itemId);
|
|
227
|
+
},
|
|
228
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
229
|
+
[cartId]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const applyDiscount = useCallback(
|
|
233
|
+
async (code: string) => {
|
|
234
|
+
if (!cartId) throw new Error('No cart ID');
|
|
235
|
+
await applyDiscountMutation.mutateAsync({ code });
|
|
236
|
+
},
|
|
237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
238
|
+
[cartId]
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const removeDiscount = useCallback(async () => {
|
|
242
|
+
if (!cartId) throw new Error('No cart ID');
|
|
243
|
+
await removeDiscountMutation.mutateAsync();
|
|
244
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
245
|
+
}, [cartId]);
|
|
246
|
+
|
|
247
|
+
const clearCart = useCallback(() => {
|
|
248
|
+
setCartId(null);
|
|
249
|
+
if (typeof window !== 'undefined') {
|
|
250
|
+
localStorage.removeItem(CART_ID_KEY);
|
|
251
|
+
}
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
const open = useCallback(() => {
|
|
255
|
+
setIsOpen(true);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
const close = useCallback(() => {
|
|
259
|
+
setIsOpen(false);
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const value: CartContextValue = {
|
|
263
|
+
cart: cart || null,
|
|
264
|
+
cartId,
|
|
265
|
+
isLoading,
|
|
266
|
+
error: error as Error | null,
|
|
267
|
+
updateCartMutation,
|
|
268
|
+
checkoutMutation,
|
|
269
|
+
addItem,
|
|
270
|
+
updateItem,
|
|
271
|
+
removeItem,
|
|
272
|
+
applyDiscount,
|
|
273
|
+
removeDiscount,
|
|
274
|
+
clearCart,
|
|
275
|
+
refetch,
|
|
276
|
+
isOpen,
|
|
277
|
+
open,
|
|
278
|
+
close,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<CartContext.Provider value={value}>
|
|
283
|
+
{children}
|
|
284
|
+
{/* Only render ShoppingCart on client to avoid hydration mismatch */}
|
|
285
|
+
{isMounted && (
|
|
286
|
+
<ShoppingCart
|
|
287
|
+
isOpen={isOpen}
|
|
288
|
+
onClose={close}
|
|
289
|
+
{...shoppingCartProps}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</CartContext.Provider>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Hook to access cart context
|
|
298
|
+
*/
|
|
299
|
+
export function useCart() {
|
|
300
|
+
const context = useContext(CartContext);
|
|
301
|
+
if (!context) {
|
|
302
|
+
throw new Error('useCart must be used within CartProvider');
|
|
303
|
+
}
|
|
304
|
+
return context;
|
|
305
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paystack Payment Hook
|
|
5
|
+
*
|
|
6
|
+
* Provides integration with Paystack Inline JS for processing online payments.
|
|
7
|
+
* Handles script loading, popup initialization, and payment callbacks.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
11
|
+
|
|
12
|
+
// Paystack Inline types
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
PaystackPop?: {
|
|
16
|
+
setup: (config: PaystackConfig) => {
|
|
17
|
+
openIframe: () => void;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PaystackConfig {
|
|
24
|
+
key: string;
|
|
25
|
+
email: string;
|
|
26
|
+
amount: number; // Amount in kobo (smallest currency unit)
|
|
27
|
+
currency?: string;
|
|
28
|
+
ref?: string;
|
|
29
|
+
metadata?: {
|
|
30
|
+
custom_fields?: Array<{
|
|
31
|
+
display_name: string;
|
|
32
|
+
variable_name: string;
|
|
33
|
+
value: string | number;
|
|
34
|
+
}>;
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
};
|
|
37
|
+
callback?: (response: PaystackResponse) => void;
|
|
38
|
+
onClose?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PaystackResponse {
|
|
42
|
+
reference: string;
|
|
43
|
+
status: string;
|
|
44
|
+
trans: string;
|
|
45
|
+
transaction: string;
|
|
46
|
+
trxref: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UsePaystackPaymentOptions {
|
|
51
|
+
publicKey: string;
|
|
52
|
+
onSuccess?: (response: PaystackResponse) => void;
|
|
53
|
+
onClose?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function usePaystackPayment({
|
|
57
|
+
publicKey,
|
|
58
|
+
onSuccess,
|
|
59
|
+
onClose,
|
|
60
|
+
}: UsePaystackPaymentOptions) {
|
|
61
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
62
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
64
|
+
|
|
65
|
+
// Load Paystack Inline script
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// Check if script is already loaded
|
|
68
|
+
if (window.PaystackPop) {
|
|
69
|
+
setIsLoaded(true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if script tag already exists
|
|
74
|
+
const existingScript = document.querySelector('script[src="https://js.paystack.co/v1/inline.js"]');
|
|
75
|
+
if (existingScript) {
|
|
76
|
+
existingScript.addEventListener('load', () => setIsLoaded(true));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Load script
|
|
81
|
+
const script = document.createElement('script');
|
|
82
|
+
script.src = 'https://js.paystack.co/v1/inline.js';
|
|
83
|
+
script.async = true;
|
|
84
|
+
script.onload = () => {
|
|
85
|
+
setIsLoaded(true);
|
|
86
|
+
};
|
|
87
|
+
script.onerror = () => {
|
|
88
|
+
setError('Failed to load Paystack script');
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
document.body.appendChild(script);
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
// Don't remove script on unmount as it can be reused
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const initializePayment = useCallback(
|
|
99
|
+
(config: Omit<PaystackConfig, 'key' | 'callback' | 'onClose'>) => {
|
|
100
|
+
if (!isLoaded || !window.PaystackPop) {
|
|
101
|
+
setError('Paystack script not loaded');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setIsLoading(true);
|
|
106
|
+
setError(null);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const handler = window.PaystackPop.setup({
|
|
110
|
+
...config,
|
|
111
|
+
key: publicKey,
|
|
112
|
+
callback: (response) => {
|
|
113
|
+
setIsLoading(false);
|
|
114
|
+
onSuccess?.(response);
|
|
115
|
+
},
|
|
116
|
+
onClose: () => {
|
|
117
|
+
setIsLoading(false);
|
|
118
|
+
onClose?.();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
handler.openIframe();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
setIsLoading(false);
|
|
125
|
+
setError(err instanceof Error ? err.message : 'Failed to initialize payment');
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[isLoaded, publicKey, onSuccess, onClose]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
isLoaded,
|
|
133
|
+
isLoading,
|
|
134
|
+
error,
|
|
135
|
+
initializePayment,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oms/storefront-ui
|
|
3
|
+
*
|
|
4
|
+
* Pre-built UI components for OMS e-commerce sites
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Export all-in-one provider (recommended)
|
|
8
|
+
export { StorefrontProvider } from './providers/StorefrontProvider';
|
|
9
|
+
export type { StorefrontProviderProps } from './providers/StorefrontProvider';
|
|
10
|
+
|
|
11
|
+
// Export cart context (use this instead of prop drilling)
|
|
12
|
+
export { CartProvider, useCart } from './contexts/CartContext';
|
|
13
|
+
|
|
14
|
+
// Export main components
|
|
15
|
+
export { OrderConfirmation } from './components/OrderConfirmation';
|
|
16
|
+
export type { OrderConfirmationProps } from './components/OrderConfirmation';
|
|
17
|
+
|
|
18
|
+
export { ProductCard } from './components/ProductCard';
|
|
19
|
+
export type { ProductCardProps, ProductCardProduct } from './components/ProductCard';
|
|
20
|
+
|
|
21
|
+
export { ProductGrid } from './components/ProductGrid';
|
|
22
|
+
export type { ProductGridProps } from './components/ProductGrid';
|
|
23
|
+
|
|
24
|
+
export { CartItem } from './components/CartItem';
|
|
25
|
+
export type { CartItemProps } from './components/CartItem';
|
|
26
|
+
|
|
27
|
+
export { ShoppingCart } from './components/ShoppingCart';
|
|
28
|
+
export type { ShoppingCartProps } from './components/ShoppingCart';
|
|
29
|
+
|
|
30
|
+
export { DiscountCodeInput } from './components/DiscountCodeInput';
|
|
31
|
+
export type { DiscountCodeInputProps } from './components/DiscountCodeInput';
|
|
32
|
+
|
|
33
|
+
export { Checkout } from './components/Checkout';
|
|
34
|
+
export type { CheckoutProps, CheckoutFormData } from './components/Checkout';
|
|
35
|
+
|
|
36
|
+
// Export UI primitives (for customization)
|
|
37
|
+
export { Button } from './components/ui/button';
|
|
38
|
+
export { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from './components/ui/card';
|
|
39
|
+
export { Badge } from './components/ui/badge';
|
|
40
|
+
export { Modal } from './components/ui/modal';
|
|
41
|
+
export type { ModalProps } from './components/ui/modal';
|
|
42
|
+
export { FormInput } from './components/ui/form-input';
|
|
43
|
+
export type { FormInputProps } from './components/ui/form-input';
|
|
44
|
+
export { FormSelect } from './components/ui/form-select';
|
|
45
|
+
export type { FormSelectProps } from './components/ui/form-select';
|
|
46
|
+
|
|
47
|
+
// Export utilities
|
|
48
|
+
export { cn, formatCurrency, formatDate, formatDateTime, getStatusColor } from './lib/utils';
|
|
49
|
+
|
|
50
|
+
// Re-export API client hooks for convenience
|
|
51
|
+
export * from '@oms/api-client';
|