@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,263 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ShoppingCart Component
5
+ *
6
+ * Animated sliding cart sidebar that opens from the right.
7
+ * Uses CartProvider for cart data.
8
+ */
9
+
10
+ import { useEffect, useState } from 'react';
11
+ import { useCart } from '../contexts/CartContext';
12
+ import { CartItem } from './CartItem';
13
+ import { DiscountCodeInput } from './DiscountCodeInput';
14
+ import { Checkout } from './Checkout';
15
+ import { Button } from './ui/button';
16
+ import { X, Package, Loader2 } from 'lucide-react';
17
+ import { formatCurrency, cn } from '../lib/utils';
18
+
19
+ export interface ShoppingCartProps {
20
+ /** Whether the cart is open */
21
+ isOpen: boolean;
22
+ /** Callback when cart should close */
23
+ onClose: () => void;
24
+ /** Callback when checkout is clicked */
25
+ onCheckout?: () => void;
26
+ /** Callback when continue shopping is clicked */
27
+ onContinueShopping?: () => void;
28
+ /** Custom class name */
29
+ className?: string;
30
+ /** Show discount code input */
31
+ showDiscountCode?: boolean;
32
+ /** Checkout button text */
33
+ checkoutButtonText?: string;
34
+ /** Continue shopping button text */
35
+ continueShoppingText?: string;
36
+ /** Custom class for checkout button */
37
+ checkoutButtonClassName?: string;
38
+ /** Custom class for continue shopping button */
39
+ continueShoppingButtonClassName?: string;
40
+ /** Disable checkout */
41
+ disableCheckout?: boolean;
42
+ /** Empty cart message */
43
+ emptyMessage?: string;
44
+ }
45
+
46
+ export function ShoppingCart({
47
+ isOpen,
48
+ onClose,
49
+ onCheckout,
50
+ onContinueShopping,
51
+ className,
52
+ showDiscountCode = true,
53
+ checkoutButtonText = 'Checkout',
54
+ continueShoppingText = 'Continue Shopping',
55
+ checkoutButtonClassName,
56
+ continueShoppingButtonClassName,
57
+ disableCheckout = false,
58
+ emptyMessage = 'Your cart is empty',
59
+ }: ShoppingCartProps) {
60
+ // Get cart from CartProvider context
61
+ const { cart, isLoading, error } = useCart();
62
+
63
+ // State for checkout modal
64
+ const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
65
+
66
+ // Prevent body scroll when cart is open
67
+ useEffect(() => {
68
+ if (isOpen) {
69
+ document.body.style.overflow = 'hidden';
70
+ } else {
71
+ document.body.style.overflow = '';
72
+ }
73
+ return () => {
74
+ document.body.style.overflow = '';
75
+ };
76
+ }, [isOpen]);
77
+
78
+ // Close on escape key
79
+ useEffect(() => {
80
+ const handleEscape = (e: KeyboardEvent) => {
81
+ if (e.key === 'Escape' && isOpen) {
82
+ onClose();
83
+ }
84
+ };
85
+ window.addEventListener('keydown', handleEscape);
86
+ return () => window.removeEventListener('keydown', handleEscape);
87
+ }, [isOpen, onClose]);
88
+
89
+ // Calculate totals from cart pricing
90
+ const subtotal = cart?.pricing?.subtotal || 0;
91
+ const discount = cart?.pricing.discount?.amount || 0;
92
+
93
+ const handleCheckout = () => {
94
+ setIsCheckoutOpen(true);
95
+ onCheckout?.();
96
+ };
97
+
98
+ const handleContinueShopping = () => {
99
+ onContinueShopping?.();
100
+ onClose();
101
+ };
102
+
103
+ return (
104
+ <>
105
+ {/* Backdrop */}
106
+ <div
107
+ className={cn(
108
+ 'fixed inset-0 z-40 bg-black/50 transition-opacity duration-300',
109
+ isOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
110
+ )}
111
+ onClick={onClose}
112
+ aria-hidden="true"
113
+ />
114
+
115
+ {/* Sidebar */}
116
+ <div
117
+ className={cn(
118
+ 'fixed right-0 top-0 z-50 h-full w-full bg-white shadow-2xl transition-transform duration-300 ease-in-out sm:w-[480px]',
119
+ isOpen ? 'translate-x-0' : 'translate-x-full',
120
+ className
121
+ )}
122
+ >
123
+ <div className="flex h-full flex-col">
124
+ {/* Header */}
125
+ <div className="border-b border-gray-300 px-4 py-2">
126
+ <div className="flex items-center justify-between">
127
+ <div className="flex items-center gap-2">
128
+ <div className="bg-accent-500 text-white px-2 py-1 rounded-full text-sm aspect-square flex items-center justify-center w-8 h-8">{cart?.items?.length}</div>
129
+ <h2 className="text-lg font-medium">Your Cart</h2>
130
+ </div>
131
+ <button
132
+ onClick={onClose}
133
+ className="rounded-full p-2 transition-colors hover:bg-gray-100"
134
+ aria-label="Close cart"
135
+ >
136
+ <X className="h-6 w-6" />
137
+ </button>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Loading State */}
142
+ {isLoading && (
143
+ <div className="flex flex-1 items-center justify-center">
144
+ <div className="text-center">
145
+ <Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-accent-500" />
146
+ <p className="text-gray-600">Loading cart...</p>
147
+ </div>
148
+ </div>
149
+ )}
150
+
151
+ {/* Error State */}
152
+ {error && !isLoading && (
153
+ <div className="flex flex-1 items-center justify-center">
154
+ <div className="text-center">
155
+ <Package className="mx-auto mb-4 h-12 w-12 text-red-600" />
156
+ <p className="text-red-600">Failed to load cart. Please try again.</p>
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ {/* Empty State */}
162
+ {!isLoading && !error && (!cart || !cart.items || cart.items.length === 0) && (
163
+ <div className="flex flex-1 items-center justify-center">
164
+ <div className="text-center">
165
+ <Package className="mx-auto mb-4 h-12 w-12 text-gray-400" />
166
+ <p className="text-gray-600">{emptyMessage}</p>
167
+ </div>
168
+ </div>
169
+ )}
170
+
171
+ {/* Cart Content */}
172
+ {!isLoading && !error && cart && cart.items && cart.items.length > 0 && (
173
+ <>
174
+ {/* Cart Items - Scrollable */}
175
+ <div className="flex-1 overflow-y-auto p-4">
176
+ <div className="space-y-4">
177
+ {cart.items.map((item, index) => (
178
+ <div key={item.id}>
179
+ <CartItem
180
+ item={item}
181
+ />
182
+ {index < cart.items.length - 1 && (
183
+ <hr className="border-gray-200 mt-2" />
184
+ )}
185
+ </div>
186
+ ))}
187
+ </div>
188
+
189
+ {/* Discount Code */}
190
+ {showDiscountCode && (
191
+ <div className="mt-6">
192
+ <DiscountCodeInput />
193
+ </div>
194
+ )}
195
+ </div>
196
+
197
+ {/* Footer with Summary and Actions */}
198
+ <div className="border-t border-gray-300 bg-white">
199
+ {/* Summary */}
200
+ <div className=" text-base">
201
+ <div className="p-4 space-y-2">
202
+ <div className="flex justify-between">
203
+ <span className="text-gray-600">Subtotal</span>
204
+ <span className="font-semibold">{formatCurrency(subtotal)}</span>
205
+ </div>
206
+
207
+ {discount > 0 && (
208
+ <div className="flex justify-between text-green-600">
209
+ <span>Discount</span>
210
+ <span className="font-semibold">-{formatCurrency(discount)}</span>
211
+ </div>
212
+ )}
213
+
214
+ <div className="flex justify-between">
215
+ <span className="text-gray-600">Shipping</span>
216
+ <span className="font-semibold">
217
+ {cart.pricing && cart.deliveryZone ? formatCurrency(cart.pricing.deliveryCharge) : '-'}
218
+ </span>
219
+ </div>
220
+
221
+ </div>
222
+
223
+ <div className="flex justify-between p-4 border-t border-gray-300 text-lg">
224
+ <span className="font-bold">Total</span>
225
+ <span className="font-bold">{formatCurrency(cart.pricing.total)}</span>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Action Buttons */}
230
+ <div className="p-4 space-y-2">
231
+ <Button
232
+ onClick={handleCheckout}
233
+ disabled={disableCheckout || cart.items.length === 0}
234
+ className={cn('w-full bg-accent-500 text-white hover:bg-accent-600', checkoutButtonClassName)}
235
+ size="lg"
236
+ >
237
+ <Package className="h-5 w-5" />
238
+ {checkoutButtonText}
239
+ </Button>
240
+ <Button
241
+ onClick={handleContinueShopping}
242
+ variant="outline"
243
+ className={cn('w-full border-gray-300 text-gray-600 hover:bg-gray-100', continueShoppingButtonClassName)}
244
+ size="lg"
245
+ >
246
+ {continueShoppingText}
247
+ </Button>
248
+ </div>
249
+ </div>
250
+ </>
251
+ )}
252
+ </div>
253
+ </div>
254
+
255
+ {/* Checkout Modal */}
256
+ <Checkout
257
+ isOpen={isCheckoutOpen}
258
+ onSuccess={() => onClose()}
259
+ onClose={() => setIsCheckoutOpen(false)}
260
+ />
261
+ </>
262
+ );
263
+ }
@@ -0,0 +1,37 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
37
+
@@ -0,0 +1,71 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { Loader2 } from "lucide-react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const buttonVariants = cva(
9
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
+ outline:
17
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
20
+ ghost:
21
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ "icon-sm": "size-8",
30
+ "icon-lg": "size-10",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: "default",
35
+ size: "default",
36
+ },
37
+ }
38
+ )
39
+
40
+ function Button({
41
+ className,
42
+ variant,
43
+ size,
44
+ asChild = false,
45
+ loading = false,
46
+ children,
47
+ disabled,
48
+ type,
49
+ ...props
50
+ }: React.ComponentProps<"button"> &
51
+ VariantProps<typeof buttonVariants> & {
52
+ asChild?: boolean
53
+ loading?: boolean
54
+ }) {
55
+ const Comp = asChild ? Slot : "button"
56
+
57
+ return (
58
+ <Comp
59
+ data-slot="button"
60
+ className={cn(buttonVariants({ variant, size, className }))}
61
+ disabled={disabled || loading}
62
+ type={type ?? "button"}
63
+ {...props}
64
+ >
65
+ {loading && <Loader2 className="size-4 animate-spin" />}
66
+ {children}
67
+ </Comp>
68
+ )
69
+ }
70
+
71
+ export { Button, buttonVariants }
@@ -0,0 +1,79 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-lg border bg-card text-card-foreground",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Card.displayName = "Card"
19
+
20
+ const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
27
+ {...props}
28
+ />
29
+ ))
30
+ CardHeader.displayName = "CardHeader"
31
+
32
+ const CardTitle = React.forwardRef<
33
+ HTMLDivElement,
34
+ React.HTMLAttributes<HTMLDivElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <div
37
+ ref={ref}
38
+ className={cn(
39
+ "text-2xl font-semibold leading-none tracking-tight",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ ))
45
+ CardTitle.displayName = "CardTitle"
46
+
47
+ const CardDescription = React.forwardRef<
48
+ HTMLDivElement,
49
+ React.HTMLAttributes<HTMLDivElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ CardDescription.displayName = "CardDescription"
58
+
59
+ const CardContent = React.forwardRef<
60
+ HTMLDivElement,
61
+ React.HTMLAttributes<HTMLDivElement>
62
+ >(({ className, ...props }, ref) => (
63
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
64
+ ))
65
+ CardContent.displayName = "CardContent"
66
+
67
+ const CardFooter = React.forwardRef<
68
+ HTMLDivElement,
69
+ React.HTMLAttributes<HTMLDivElement>
70
+ >(({ className, ...props }, ref) => (
71
+ <div
72
+ ref={ref}
73
+ className={cn("flex items-center p-6 pt-0", className)}
74
+ {...props}
75
+ />
76
+ ))
77
+ CardFooter.displayName = "CardFooter"
78
+
79
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * FormInput Component
3
+ *
4
+ * A styled input field with label, error handling, and optional icon.
5
+ */
6
+
7
+ import React from 'react';
8
+ import { cn } from '../../lib/utils';
9
+
10
+ export interface FormInputProps
11
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
12
+ /** Input label */
13
+ label?: string;
14
+ /** Error message to display */
15
+ error?: string;
16
+ /** Optional icon to display inside the input */
17
+ icon?: React.ReactNode;
18
+ /** Callback when input value changes */
19
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
20
+ /** Additional CSS class for the container */
21
+ containerClassName?: string;
22
+ }
23
+
24
+ export const FormInput = React.forwardRef<HTMLInputElement, FormInputProps>(
25
+ (
26
+ {
27
+ label,
28
+ error,
29
+ icon,
30
+ className,
31
+ containerClassName,
32
+ required,
33
+ disabled,
34
+ ...props
35
+ },
36
+ ref
37
+ ) => {
38
+ return (
39
+ <div className={cn('w-full', containerClassName)}>
40
+ {label && (
41
+ <label className="mb-1.5 block text-sm font-medium text-gray-700">
42
+ {label}
43
+ {required && <span className="ml-1 text-red-600">*</span>}
44
+ </label>
45
+ )}
46
+ <div className="relative">
47
+ {icon && (
48
+ <div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
49
+ {icon}
50
+ </div>
51
+ )}
52
+ <input
53
+ ref={ref}
54
+ disabled={disabled}
55
+ className={cn(
56
+ 'w-full rounded-lg border px-3 py-2.5 text-sm transition-colors',
57
+ 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50',
58
+ icon && 'pl-10',
59
+ error
60
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500'
61
+ : 'border-gray-300 focus:border-blue-500',
62
+ disabled && 'bg-gray-50 text-gray-500 cursor-not-allowed',
63
+ className
64
+ )}
65
+ {...props}
66
+ />
67
+ </div>
68
+ {error && (
69
+ <p className="mt-1.5 text-sm text-red-600" role="alert">
70
+ {error}
71
+ </p>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+ );
77
+
78
+ FormInput.displayName = 'FormInput';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * FormSelect Component
3
+ *
4
+ * A styled select dropdown with label and error handling.
5
+ */
6
+
7
+ import React from 'react';
8
+ import { cn } from '../../lib/utils';
9
+
10
+ export interface FormSelectProps
11
+ extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
12
+ /** Select label */
13
+ label?: string;
14
+ /** Error message to display */
15
+ error?: string;
16
+ /** Callback when select value changes */
17
+ onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
18
+ /** Additional CSS class for the container */
19
+ containerClassName?: string;
20
+ }
21
+
22
+ export const FormSelect = React.forwardRef<HTMLSelectElement, FormSelectProps>(
23
+ (
24
+ {
25
+ label,
26
+ error,
27
+ className,
28
+ containerClassName,
29
+ required,
30
+ disabled,
31
+ children,
32
+ ...props
33
+ },
34
+ ref
35
+ ) => {
36
+ return (
37
+ <div className={cn('w-full', containerClassName)}>
38
+ {label && (
39
+ <label className="mb-1.5 block text-sm font-medium text-gray-700">
40
+ {label}
41
+ {required && <span className="ml-1 text-red-600">*</span>}
42
+ </label>
43
+ )}
44
+ <select
45
+ ref={ref}
46
+ disabled={disabled}
47
+ className={cn(
48
+ 'w-full rounded-lg border px-3 py-2.5 text-sm transition-colors',
49
+ 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50',
50
+ 'appearance-none bg-white',
51
+ 'bg-[url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'12\' viewBox=\'0 0 12 12\'%3E%3Cpath fill=\'%23666\' d=\'M6 9L1 4h10z\'/%3E%3C/svg%3E")] bg-no-repeat bg-[right_0.75rem_center]',
52
+ 'pr-10',
53
+ error
54
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500'
55
+ : 'border-gray-300 focus:border-blue-500',
56
+ disabled && 'bg-gray-50 text-gray-500 cursor-not-allowed',
57
+ className
58
+ )}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </select>
63
+ {error && (
64
+ <p className="mt-1.5 text-sm text-red-600" role="alert">
65
+ {error}
66
+ </p>
67
+ )}
68
+ </div>
69
+ );
70
+ }
71
+ );
72
+
73
+ FormSelect.displayName = 'FormSelect';