@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.
- 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/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 +83 -73
- 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 +1 -1
- package/dist/index82.mjs +1 -1
- package/dist/index83.mjs +5 -5
- package/dist/index85.mjs +2 -2
- package/dist/index87.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 +1 -1
- package/dist/index94.mjs +3 -3
- 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,262 @@
|
|
|
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
|
+
onClose={() => setIsCheckoutOpen(false)}
|
|
259
|
+
/>
|
|
260
|
+
</>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -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';
|