@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.
Files changed (98) hide show
  1. package/README.md +26 -0
  2. package/dist/components/Checkout.d.ts.map +1 -1
  3. package/dist/components/ShoppingCart.d.ts.map +1 -1
  4. package/dist/contexts/CartContext.d.ts.map +1 -1
  5. package/dist/index10.mjs +144 -141
  6. package/dist/index101.mjs +1 -1
  7. package/dist/index102.mjs +3 -3
  8. package/dist/index103.mjs +3 -3
  9. package/dist/index105.mjs +1 -1
  10. package/dist/index111.mjs +1 -1
  11. package/dist/index20.mjs +2 -2
  12. package/dist/index21.mjs +1 -1
  13. package/dist/index28.mjs +11 -11
  14. package/dist/index3.mjs +88 -78
  15. package/dist/index37.mjs +1 -1
  16. package/dist/index41.mjs +36 -23
  17. package/dist/index42.mjs +44 -36
  18. package/dist/index43.mjs +99 -44
  19. package/dist/index44.mjs +112 -99
  20. package/dist/index45.mjs +44 -80
  21. package/dist/index46.mjs +64 -53
  22. package/dist/index47.mjs +65 -48
  23. package/dist/index48.mjs +54 -73
  24. package/dist/index49.mjs +52 -63
  25. package/dist/index50.mjs +14 -70
  26. package/dist/index51.mjs +13 -14
  27. package/dist/index52.mjs +58 -13
  28. package/dist/index53.mjs +101 -34
  29. package/dist/index54.mjs +99 -95
  30. package/dist/index55.mjs +22 -132
  31. package/dist/index62.mjs +30 -231
  32. package/dist/index63.mjs +42 -5
  33. package/dist/index64.mjs +228 -127
  34. package/dist/index65.mjs +4 -66
  35. package/dist/index66.mjs +124 -77
  36. package/dist/index67.mjs +65 -26
  37. package/dist/index68.mjs +84 -6
  38. package/dist/index69.mjs +26 -72
  39. package/dist/index70.mjs +8 -3
  40. package/dist/index71.mjs +75 -2
  41. package/dist/index72.mjs +3 -82
  42. package/dist/index73.mjs +2 -54
  43. package/dist/index74.mjs +82 -5
  44. package/dist/index75.mjs +53 -4
  45. package/dist/index76.mjs +5 -178
  46. package/dist/index77.mjs +5 -53
  47. package/dist/index78.mjs +178 -68
  48. package/dist/index79.mjs +50 -31
  49. package/dist/index8.mjs +8 -7
  50. package/dist/index80.mjs +69 -43
  51. package/dist/index81.mjs +2 -2
  52. package/dist/index82.mjs +1 -1
  53. package/dist/index83.mjs +6 -6
  54. package/dist/index84.mjs +2 -2
  55. package/dist/index85.mjs +2 -2
  56. package/dist/index87.mjs +2 -2
  57. package/dist/index88.mjs +2 -2
  58. package/dist/index89.mjs +1 -1
  59. package/dist/index91.mjs +4 -4
  60. package/dist/index92.mjs +3 -3
  61. package/dist/index93.mjs +12 -30
  62. package/dist/index94.mjs +7 -11
  63. package/dist/index95.mjs +30 -3
  64. package/dist/index96.mjs +10 -3
  65. package/dist/index97.mjs +4 -13
  66. package/dist/index98.mjs +4 -7
  67. package/dist/index99.mjs +1 -1
  68. package/dist/styles.css +1 -0
  69. package/package.json +14 -13
  70. package/src/components/CartItem.stories.tsx +94 -0
  71. package/src/components/CartItem.tsx +141 -0
  72. package/src/components/Checkout.stories.tsx +380 -0
  73. package/src/components/Checkout.tsx +954 -0
  74. package/src/components/DiscountCodeInput.stories.tsx +76 -0
  75. package/src/components/DiscountCodeInput.tsx +162 -0
  76. package/src/components/OrderConfirmation.stories.tsx +142 -0
  77. package/src/components/OrderConfirmation.tsx +301 -0
  78. package/src/components/ProductCard.stories.tsx +112 -0
  79. package/src/components/ProductCard.tsx +195 -0
  80. package/src/components/ProductGrid.stories.tsx +137 -0
  81. package/src/components/ProductGrid.tsx +141 -0
  82. package/src/components/ShoppingCart.stories.tsx +459 -0
  83. package/src/components/ShoppingCart.tsx +263 -0
  84. package/src/components/ui/badge.tsx +37 -0
  85. package/src/components/ui/button.tsx +71 -0
  86. package/src/components/ui/card.tsx +79 -0
  87. package/src/components/ui/form-input.tsx +78 -0
  88. package/src/components/ui/form-select.tsx +73 -0
  89. package/src/components/ui/modal.tsx +181 -0
  90. package/src/contexts/CartContext.tsx +316 -0
  91. package/src/hooks/usePaystackPayment.ts +137 -0
  92. package/src/index.ts +51 -0
  93. package/src/lib/utils.ts +45 -0
  94. package/src/paystack.svg +67 -0
  95. package/src/providers/StorefrontProvider.tsx +70 -0
  96. package/src/styles.css +1 -0
  97. package/src/test-utils/MockCartProvider.tsx +424 -0
  98. 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,316 @@
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 hasLoadedFromStorageRef = useRef(false);
88
+ const hasAutoCreatedRef = useRef(false);
89
+ const isHandlingErrorRef = useRef(false);
90
+
91
+ // Set mounted flag on client
92
+ useEffect(() => {
93
+ setIsMounted(true);
94
+ }, []);
95
+
96
+ // Load cartId from URL/localStorage after mount (client-side only)
97
+ // This avoids hydration mismatch between server and client
98
+ useEffect(() => {
99
+ if (initialCartId || hasLoadedFromStorageRef.current) return;
100
+
101
+ hasLoadedFromStorageRef.current = true;
102
+
103
+ // Check URL query params first
104
+ const urlParams = new URLSearchParams(window.location.search);
105
+ const cartIdFromUrl = urlParams.get('cartId');
106
+ if (cartIdFromUrl) {
107
+ setCartId(cartIdFromUrl);
108
+ return;
109
+ }
110
+
111
+ // Fall back to localStorage
112
+ const storedCartId = localStorage.getItem(CART_ID_KEY);
113
+ if (storedCartId) {
114
+ setCartId(storedCartId);
115
+ }
116
+ }, [initialCartId]);
117
+
118
+ // Fetch cart if we have a cartId
119
+ const { data: cart, isLoading, error, refetch } = useGetCart(cartId || '', {
120
+ enabled: !!cartId,
121
+ retry: false, // Don't retry on failure - cart might be invalid/expired
122
+ });
123
+
124
+ // Auto-create cart if none exists
125
+ const createCartMutation = useCreateCart({
126
+ onSuccess: (data) => {
127
+ // Check if response is an error
128
+ if ('error' in data) {
129
+ console.error('Failed to create cart:', data.error);
130
+ return;
131
+ }
132
+
133
+ setCartId(data.id);
134
+ },
135
+ onError: (error) => {
136
+ console.error('Failed to create cart:', error);
137
+ },
138
+ });
139
+
140
+ // Auto-create cart ONLY on initial mount if no cartId in localStorage
141
+ // After the first mount, we don't auto-create (user must explicitly add items)
142
+ useEffect(() => {
143
+ // Only run on initial mount
144
+ if (hasAutoCreatedRef.current) return;
145
+
146
+ hasAutoCreatedRef.current = true;
147
+
148
+ // Defer cart creation to allow localStorage to load first
149
+ const timeoutId = setTimeout(() => {
150
+ // Only create if we still don't have a cartId after localStorage loaded
151
+ if (!cartId && !initialCartId) {
152
+ createCartMutation.mutate(brandSlug);
153
+ }
154
+ }, 150); // Delay to ensure localStorage effect has run
155
+
156
+ return () => clearTimeout(timeoutId);
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, []); // Empty deps - truly only run once on mount
159
+
160
+ // Handle cart fetch error (invalid/expired cart from URL or localStorage)
161
+ // Clear the invalid cart and create a new one
162
+ useEffect(() => {
163
+ if (error && cartId && !isHandlingErrorRef.current) {
164
+ isHandlingErrorRef.current = true;
165
+ console.warn('Cart fetch failed, creating new cart:', error);
166
+
167
+ // Clear invalid cart
168
+ setCartId(null);
169
+ if (typeof window !== 'undefined') {
170
+ localStorage.removeItem(CART_ID_KEY);
171
+ }
172
+
173
+ // Create a new cart after clearing the invalid one
174
+ setTimeout(() => {
175
+ createCartMutation.mutate(brandSlug);
176
+ isHandlingErrorRef.current = false;
177
+ }, 100);
178
+ }
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }, [error, cartId, brandSlug]); // Don't include createCartMutation to avoid infinite loop
181
+
182
+ // Mutations
183
+ const addItemMutation = useAddCartItem(cartId || '', {
184
+ onSuccess: () => refetch(),
185
+ });
186
+
187
+ const updateItemMutation = useUpdateCartItem(cartId || '', {
188
+ onSuccess: () => refetch(),
189
+ });
190
+
191
+ const removeItemMutation = useRemoveCartItem(cartId || '', {
192
+ onSuccess: () => refetch(),
193
+ });
194
+
195
+ const applyDiscountMutation = useApplyDiscount(cartId || '', {
196
+ onSuccess: () => refetch(),
197
+ });
198
+
199
+ const removeDiscountMutation = useRemoveDiscount(cartId || '', {
200
+ onSuccess: () => refetch(),
201
+ });
202
+
203
+ const updateCartMutation = useUpdateCart(cartId || '', {
204
+ onSuccess: () => refetch(),
205
+ });
206
+
207
+ const checkoutMutation = useCheckoutCart(cartId || '');
208
+
209
+ // Store cartId in localStorage when it changes
210
+ useEffect(() => {
211
+ if (cartId && typeof window !== 'undefined') {
212
+ localStorage.setItem(CART_ID_KEY, cartId);
213
+ }
214
+ }, [cartId]);
215
+
216
+ const addItem = useCallback(
217
+ async (sku: string, quantity: number) => {
218
+ if (!cartId) throw new Error('No cart ID');
219
+ await addItemMutation.mutateAsync({ sku, quantity });
220
+ },
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ [cartId]
223
+ );
224
+
225
+ const updateItem = useCallback(
226
+ async (itemId: string, quantity: number) => {
227
+ if (!cartId) throw new Error('No cart ID');
228
+ await updateItemMutation.mutateAsync({ itemId, quantity });
229
+ },
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ [cartId]
232
+ );
233
+
234
+ const removeItem = useCallback(
235
+ async (itemId: string) => {
236
+ if (!cartId) throw new Error('No cart ID');
237
+ await removeItemMutation.mutateAsync(itemId);
238
+ },
239
+ // eslint-disable-next-line react-hooks/exhaustive-deps
240
+ [cartId]
241
+ );
242
+
243
+ const applyDiscount = useCallback(
244
+ async (code: string) => {
245
+ if (!cartId) throw new Error('No cart ID');
246
+ await applyDiscountMutation.mutateAsync({ code });
247
+ },
248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
+ [cartId]
250
+ );
251
+
252
+ const removeDiscount = useCallback(async () => {
253
+ if (!cartId) throw new Error('No cart ID');
254
+ await removeDiscountMutation.mutateAsync();
255
+ // eslint-disable-next-line react-hooks/exhaustive-deps
256
+ }, [cartId]);
257
+
258
+ const clearCart = useCallback(() => {
259
+ setCartId(null);
260
+ if (typeof window !== 'undefined') {
261
+ localStorage.removeItem(CART_ID_KEY);
262
+ }
263
+ }, []);
264
+
265
+ const open = useCallback(() => {
266
+ setIsOpen(true);
267
+ }, []);
268
+
269
+ const close = useCallback(() => {
270
+ setIsOpen(false);
271
+ }, []);
272
+
273
+ const value: CartContextValue = {
274
+ cart: cart || null,
275
+ cartId,
276
+ isLoading,
277
+ error: error as Error | null,
278
+ updateCartMutation,
279
+ checkoutMutation,
280
+ addItem,
281
+ updateItem,
282
+ removeItem,
283
+ applyDiscount,
284
+ removeDiscount,
285
+ clearCart,
286
+ refetch,
287
+ isOpen,
288
+ open,
289
+ close,
290
+ };
291
+
292
+ return (
293
+ <CartContext.Provider value={value}>
294
+ {children}
295
+ {/* Only render ShoppingCart on client to avoid hydration mismatch */}
296
+ {isMounted && (
297
+ <ShoppingCart
298
+ isOpen={isOpen}
299
+ onClose={close}
300
+ {...shoppingCartProps}
301
+ />
302
+ )}
303
+ </CartContext.Provider>
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Hook to access cart context
309
+ */
310
+ export function useCart() {
311
+ const context = useContext(CartContext);
312
+ if (!context) {
313
+ throw new Error('useCart must be used within CartProvider');
314
+ }
315
+ return context;
316
+ }
@@ -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';