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