@hanzo/ui 4.6.0 → 4.8.2
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/assets/general.tsx +1 -1
- package/assets/hanzo-logo.tsx +3 -1
- package/assets/index.ts +119 -5
- package/blocks/auth/index.ts +6 -0
- package/blocks/auth/login-2fa.tsx +165 -0
- package/blocks/auth/login-basic.tsx +94 -0
- package/blocks/auth/login-social.tsx +148 -0
- package/blocks/auth/magic-link.tsx +129 -0
- package/blocks/auth/password-reset.tsx +97 -0
- package/blocks/auth/signup.tsx +157 -0
- package/blocks/data-display/activity-feed.tsx +242 -0
- package/blocks/data-display/data-table.tsx +235 -0
- package/blocks/data-display/stats-grid.tsx +194 -0
- package/blocks/ecommerce/checkout.tsx +242 -0
- package/blocks/ecommerce/index.ts +7 -0
- package/blocks/ecommerce/product-detail.tsx +257 -0
- package/blocks/ecommerce/product-grid.tsx +148 -0
- package/blocks/ecommerce/shopping-cart.tsx +181 -0
- package/blocks/marketing/cta-section.tsx +207 -0
- package/blocks/marketing/faq.tsx +159 -0
- package/blocks/marketing/features-grid.tsx +156 -0
- package/blocks/marketing/hero-section.tsx +192 -0
- package/blocks/marketing/index.ts +6 -0
- package/blocks/marketing/pricing-table.tsx +121 -0
- package/blocks/marketing/testimonials.tsx +196 -0
- package/components/index.ts +4 -51
- package/dist/index.js +9351 -0
- package/dist/index.mjs +9340 -0
- package/dist/lib/utils.js +47 -0
- package/dist/lib/utils.mjs +28 -0
- package/dist/src/utils.js +47 -0
- package/dist/src/utils.mjs +28 -0
- package/dist/tailwind/index.js +2050 -0
- package/dist/tailwind/index.mjs +2019 -0
- package/dist/types/index.js +79 -0
- package/dist/types/index.mjs +56 -0
- package/dist/util/format-text.js +51 -0
- package/dist/util/format-text.mjs +32 -0
- package/dist/util/index.js +384 -0
- package/dist/util/index.mjs +363 -0
- package/frameworks/core/index.ts +6 -0
- package/frameworks/core/utils/index.ts +64 -0
- package/frameworks/react/components/button.tsx +26 -0
- package/frameworks/react/components/index.ts +5 -0
- package/frameworks/react/hooks/index.ts +5 -0
- package/frameworks/react/index.ts +9 -0
- package/frameworks/react/package.json +8 -0
- package/frameworks/react/utils/index.ts +2 -0
- package/frameworks/react-native/index.ts +9 -0
- package/frameworks/react-native/package.json +8 -0
- package/frameworks/registry.json +371 -0
- package/frameworks/setup.sh +69 -0
- package/frameworks/svelte/index.ts +9 -0
- package/frameworks/svelte/package.json +8 -0
- package/frameworks/tracker.json +1854 -0
- package/frameworks/vue/index.ts +9 -0
- package/frameworks/vue/package.json +8 -0
- package/package.json +192 -28
- package/primitives/accordion.tsx +1 -1
- package/primitives/alert-dialog.tsx +1 -1
- package/primitives/alert.tsx +1 -1
- package/primitives/avatar.tsx +1 -1
- package/primitives/badge.tsx +2 -1
- package/primitives/breadcrumb.tsx +1 -1
- package/primitives/button.tsx +37 -47
- package/primitives/card.tsx +1 -1
- package/primitives/carousel.tsx +3 -2
- package/primitives/chat/chat-input-area.tsx +5 -4
- package/primitives/chat/chat-input.tsx +2 -2
- package/primitives/chat/files-preview.tsx +5 -4
- package/primitives/chat/message-list.tsx +2 -1
- package/primitives/chat/sqlite-preview.tsx +8 -8
- package/primitives/checkbox.tsx +2 -1
- package/primitives/command.tsx +3 -1
- package/primitives/context-menu.tsx +1 -1
- package/primitives/dialog.tsx +6 -1
- package/primitives/drawer.tsx +4 -1
- package/primitives/dropdown-menu.tsx +1 -1
- package/primitives/file-uploader.tsx +4 -2
- package/primitives/form.tsx +1 -1
- package/primitives/hover-card.tsx +1 -1
- package/primitives/icons/github.tsx +2 -2
- package/primitives/icons/youtube-logo.tsx +1 -1
- package/primitives/index-common.ts +7 -6
- package/primitives/input-otp.tsx +1 -1
- package/primitives/input.tsx +2 -1
- package/primitives/label.tsx +2 -1
- package/primitives/markdown-preview.tsx +3 -0
- package/primitives/mermaid.tsx +13 -18
- package/primitives/next/image.tsx +2 -1
- package/primitives/next/inline-icon.tsx +14 -14
- package/primitives/next/media-stack.tsx +2 -19
- package/primitives/pagination.tsx +1 -1
- package/primitives/popover.tsx +4 -2
- package/primitives/progress.tsx +2 -1
- package/primitives/prompt-textarea.tsx +1 -1
- package/primitives/radio-group.tsx +1 -1
- package/primitives/scroll-area.tsx +1 -1
- package/primitives/search-input.tsx +1 -1
- package/primitives/select.tsx +1 -1
- package/primitives/separator.tsx +2 -1
- package/primitives/sheet.tsx +1 -1
- package/primitives/skeleton.tsx +1 -0
- package/primitives/slider.tsx +2 -1
- package/primitives/stepper.tsx +1 -1
- package/primitives/switch.tsx +2 -1
- package/primitives/table.tsx +1 -1
- package/primitives/tabs.tsx +1 -1
- package/primitives/textarea.tsx +2 -1
- package/primitives/textfield.tsx +1 -0
- package/primitives/toggle-group.tsx +1 -1
- package/primitives/toggle.tsx +1 -1
- package/primitives/tooltip.tsx +1 -1
- package/src/hooks/use-copy-clipboard.ts +1 -1
- package/src/index-lean.ts +87 -0
- package/src/index.ts +54 -0
- package/src/registry/api.ts +1 -1
- package/src/utils.ts +19 -1
- package/tailwind/tailwind.config.hanzo-preset.js +7 -7
- package/tailwind/typo-plugin/index.js +1 -1
- package/types/animation-def.ts +1 -1
- package/types/index.ts +2 -1
- package/util/blob.ts +9 -4
- package/util/date.ts +2 -1
- package/util/format-text.ts +2 -1
- package/util/index.ts +103 -0
- package/util/spread-to-transform.ts +9 -8
- package/MCP-INSTRUCTIONS.md +0 -73
- package/README-MCP.md +0 -175
- package/dist/button.d.ts +0 -1
- package/dist/button.js +0 -1
- package/dist/hooks/index.d.ts +0 -7
- package/dist/hooks/index.js +0 -7
- package/dist/hooks/use-click-away.d.ts +0 -2
- package/dist/hooks/use-click-away.js +0 -23
- package/dist/hooks/use-combined-refs.d.ts +0 -3
- package/dist/hooks/use-combined-refs.js +0 -18
- package/dist/hooks/use-copy-clipboard.d.ts +0 -9
- package/dist/hooks/use-copy-clipboard.js +0 -21
- package/dist/hooks/use-debounce.d.ts +0 -1
- package/dist/hooks/use-debounce.js +0 -13
- package/dist/hooks/use-fill-ids.d.ts +0 -8
- package/dist/hooks/use-fill-ids.js +0 -20
- package/dist/hooks/use-map.d.ts +0 -1
- package/dist/hooks/use-map.js +0 -20
- package/dist/hooks/use-measure.d.ts +0 -8
- package/dist/hooks/use-measure.js +0 -25
- package/dist/hooks/use-reverse-video-playback.d.ts +0 -1
- package/dist/hooks/use-reverse-video-playback.js +0 -41
- package/dist/hooks/use-scroll-restoration.d.ts +0 -8
- package/dist/hooks/use-scroll-restoration.js +0 -36
- package/dist/mcp/enhanced-server.d.ts +0 -29
- package/dist/mcp/enhanced-server.js +0 -1128
- package/dist/mcp/index.d.ts +0 -28
- package/dist/mcp/index.js +0 -436
- package/dist/registry/api.d.ts +0 -37
- package/dist/registry/api.js +0 -129
- package/dist/registry/index.d.ts +0 -353
- package/dist/registry/index.js +0 -45
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +0 -1
- package/environment.d.ts +0 -6
- package/public/r/accordion.json +0 -11
- package/public/r/alert.json +0 -11
- package/public/r/avatar.json +0 -11
- package/public/r/badge.json +0 -11
- package/public/r/button.json +0 -11
- package/public/r/card.json +0 -11
- package/public/r/checkbox.json +0 -11
- package/public/r/default.json +0 -6
- package/public/r/dialog.json +0 -11
- package/public/r/input.json +0 -11
- package/public/r/label.json +0 -11
- package/public/r/new-york.json +0 -6
- package/public/r/popover.json +0 -11
- package/public/r/select.json +0 -11
- package/public/r/table.json +0 -11
- package/public/r/tabs.json +0 -11
- package/public/r/toast.json +0 -11
- package/registry.json +0 -184
- package/test/test-registry.js +0 -73
- package/test-imports.mjs +0 -19
- package/tsconfig.json +0 -22
- package/utils.ts +0 -9
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { cn } from '@hanzo/ui/util'
|
|
5
|
+
import { Button } from '@hanzo/ui/primitives'
|
|
6
|
+
import { Badge } from '@hanzo/ui/primitives'
|
|
7
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@hanzo/ui/primitives'
|
|
8
|
+
import { Star, ShoppingCart, Heart, Share2 } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
interface ProductDetailProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
11
|
+
product: {
|
|
12
|
+
id: string | number
|
|
13
|
+
name: string
|
|
14
|
+
description: string
|
|
15
|
+
price: number
|
|
16
|
+
originalPrice?: number
|
|
17
|
+
currency?: string
|
|
18
|
+
images: string[]
|
|
19
|
+
rating?: number
|
|
20
|
+
reviewCount?: number
|
|
21
|
+
badge?: string
|
|
22
|
+
inStock?: boolean
|
|
23
|
+
variants?: Array<{
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
value: string
|
|
27
|
+
}>
|
|
28
|
+
features?: string[]
|
|
29
|
+
specifications?: Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
onAddToCart?: (quantity: number, variant?: string) => void
|
|
32
|
+
onAddToWishlist?: () => void
|
|
33
|
+
onShare?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ProductDetail({
|
|
37
|
+
className,
|
|
38
|
+
product,
|
|
39
|
+
onAddToCart,
|
|
40
|
+
onAddToWishlist,
|
|
41
|
+
onShare,
|
|
42
|
+
...props
|
|
43
|
+
}: ProductDetailProps) {
|
|
44
|
+
const [selectedImage, setSelectedImage] = useState(0)
|
|
45
|
+
const [quantity, setQuantity] = useState(1)
|
|
46
|
+
const [selectedVariant, setSelectedVariant] = useState(
|
|
47
|
+
product.variants?.[0]?.id || ''
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const formatPrice = (price: number, currency = '$') => {
|
|
51
|
+
return `${currency}${price.toFixed(2)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const calculateDiscount = (price: number, originalPrice: number) => {
|
|
55
|
+
return Math.round(((originalPrice - price) / originalPrice) * 100)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('container py-8', className)} {...props}>
|
|
60
|
+
<div className="grid gap-8 lg:grid-cols-2">
|
|
61
|
+
{/* Images */}
|
|
62
|
+
<div className="space-y-4">
|
|
63
|
+
<div className="aspect-square overflow-hidden rounded-lg">
|
|
64
|
+
<img
|
|
65
|
+
src={product.images[selectedImage]}
|
|
66
|
+
alt={product.name}
|
|
67
|
+
className="h-full w-full object-cover"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
{product.images.length > 1 && (
|
|
71
|
+
<div className="grid grid-cols-4 gap-4">
|
|
72
|
+
{product.images.map((image, i) => (
|
|
73
|
+
<button
|
|
74
|
+
key={i}
|
|
75
|
+
onClick={() => setSelectedImage(i)}
|
|
76
|
+
className={cn(
|
|
77
|
+
'aspect-square overflow-hidden rounded-lg border-2',
|
|
78
|
+
selectedImage === i ? 'border-primary' : 'border-transparent'
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<img
|
|
82
|
+
src={image}
|
|
83
|
+
alt=""
|
|
84
|
+
className="h-full w-full object-cover"
|
|
85
|
+
/>
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Product Info */}
|
|
93
|
+
<div className="space-y-6">
|
|
94
|
+
<div>
|
|
95
|
+
{product.badge && (
|
|
96
|
+
<Badge className="mb-2">{product.badge}</Badge>
|
|
97
|
+
)}
|
|
98
|
+
<h1 className="text-3xl font-bold">{product.name}</h1>
|
|
99
|
+
{product.rating !== undefined && (
|
|
100
|
+
<div className="mt-4 flex items-center gap-2">
|
|
101
|
+
<div className="flex items-center gap-1">
|
|
102
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
103
|
+
<Star
|
|
104
|
+
key={i}
|
|
105
|
+
className={cn(
|
|
106
|
+
'h-5 w-5',
|
|
107
|
+
i < Math.floor(product.rating!)
|
|
108
|
+
? 'fill-primary text-primary'
|
|
109
|
+
: 'text-muted-foreground'
|
|
110
|
+
)}
|
|
111
|
+
/>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
<span className="font-medium">{product.rating.toFixed(1)}</span>
|
|
115
|
+
{product.reviewCount !== undefined && (
|
|
116
|
+
<span className="text-muted-foreground">
|
|
117
|
+
({product.reviewCount} reviews)
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<span className="text-3xl font-bold">
|
|
127
|
+
{formatPrice(product.price, product.currency)}
|
|
128
|
+
</span>
|
|
129
|
+
{product.originalPrice && product.originalPrice > product.price && (
|
|
130
|
+
<>
|
|
131
|
+
<span className="text-xl text-muted-foreground line-through">
|
|
132
|
+
{formatPrice(product.originalPrice, product.currency)}
|
|
133
|
+
</span>
|
|
134
|
+
<Badge variant="destructive">
|
|
135
|
+
-{calculateDiscount(product.price, product.originalPrice)}%
|
|
136
|
+
</Badge>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
{product.inStock === false && (
|
|
141
|
+
<p className="text-sm text-destructive">Out of stock</p>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<p className="text-muted-foreground">{product.description}</p>
|
|
146
|
+
|
|
147
|
+
{/* Variants */}
|
|
148
|
+
{product.variants && product.variants.length > 0 && (
|
|
149
|
+
<div className="space-y-2">
|
|
150
|
+
<label className="text-sm font-medium">
|
|
151
|
+
{product.variants[0].name}
|
|
152
|
+
</label>
|
|
153
|
+
<div className="flex flex-wrap gap-2">
|
|
154
|
+
{product.variants.map((variant) => (
|
|
155
|
+
<Button
|
|
156
|
+
key={variant.id}
|
|
157
|
+
variant={selectedVariant === variant.id ? 'default' : 'outline'}
|
|
158
|
+
size="sm"
|
|
159
|
+
onClick={() => setSelectedVariant(variant.id)}
|
|
160
|
+
>
|
|
161
|
+
{variant.value}
|
|
162
|
+
</Button>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Quantity and Actions */}
|
|
169
|
+
<div className="space-y-4">
|
|
170
|
+
<div className="flex items-center gap-4">
|
|
171
|
+
<label className="text-sm font-medium">Quantity:</label>
|
|
172
|
+
<div className="flex items-center gap-2">
|
|
173
|
+
<Button
|
|
174
|
+
size="icon"
|
|
175
|
+
variant="outline"
|
|
176
|
+
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
177
|
+
disabled={quantity <= 1}
|
|
178
|
+
>
|
|
179
|
+
-
|
|
180
|
+
</Button>
|
|
181
|
+
<span className="w-12 text-center">{quantity}</span>
|
|
182
|
+
<Button
|
|
183
|
+
size="icon"
|
|
184
|
+
variant="outline"
|
|
185
|
+
onClick={() => setQuantity(quantity + 1)}
|
|
186
|
+
>
|
|
187
|
+
+
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div className="flex gap-4">
|
|
193
|
+
<Button
|
|
194
|
+
className="flex-1"
|
|
195
|
+
size="lg"
|
|
196
|
+
disabled={product.inStock === false}
|
|
197
|
+
onClick={() => onAddToCart?.(quantity, selectedVariant)}
|
|
198
|
+
>
|
|
199
|
+
<ShoppingCart className="mr-2 h-5 w-5" />
|
|
200
|
+
Add to Cart
|
|
201
|
+
</Button>
|
|
202
|
+
<Button
|
|
203
|
+
size="lg"
|
|
204
|
+
variant="outline"
|
|
205
|
+
onClick={onAddToWishlist}
|
|
206
|
+
>
|
|
207
|
+
<Heart className="h-5 w-5" />
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
size="lg"
|
|
211
|
+
variant="outline"
|
|
212
|
+
onClick={onShare}
|
|
213
|
+
>
|
|
214
|
+
<Share2 className="h-5 w-5" />
|
|
215
|
+
</Button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Tabs */}
|
|
220
|
+
{(product.features || product.specifications) && (
|
|
221
|
+
<Tabs defaultValue="features" className="w-full">
|
|
222
|
+
<TabsList className="grid w-full grid-cols-2">
|
|
223
|
+
{product.features && <TabsTrigger value="features">Features</TabsTrigger>}
|
|
224
|
+
{product.specifications && (
|
|
225
|
+
<TabsTrigger value="specifications">Specifications</TabsTrigger>
|
|
226
|
+
)}
|
|
227
|
+
</TabsList>
|
|
228
|
+
{product.features && (
|
|
229
|
+
<TabsContent value="features" className="space-y-2">
|
|
230
|
+
<ul className="list-inside list-disc space-y-1">
|
|
231
|
+
{product.features.map((feature, i) => (
|
|
232
|
+
<li key={i} className="text-sm">
|
|
233
|
+
{feature}
|
|
234
|
+
</li>
|
|
235
|
+
))}
|
|
236
|
+
</ul>
|
|
237
|
+
</TabsContent>
|
|
238
|
+
)}
|
|
239
|
+
{product.specifications && (
|
|
240
|
+
<TabsContent value="specifications">
|
|
241
|
+
<dl className="space-y-2">
|
|
242
|
+
{Object.entries(product.specifications).map(([key, value]) => (
|
|
243
|
+
<div key={key} className="flex gap-4 text-sm">
|
|
244
|
+
<dt className="min-w-[120px] font-medium">{key}:</dt>
|
|
245
|
+
<dd className="text-muted-foreground">{value}</dd>
|
|
246
|
+
</div>
|
|
247
|
+
))}
|
|
248
|
+
</dl>
|
|
249
|
+
</TabsContent>
|
|
250
|
+
)}
|
|
251
|
+
</Tabs>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@hanzo/ui/util'
|
|
4
|
+
import { Button } from '@hanzo/ui/primitives'
|
|
5
|
+
import { Card, CardContent, CardFooter } from '@hanzo/ui/primitives'
|
|
6
|
+
import { Badge } from '@hanzo/ui/primitives'
|
|
7
|
+
import { Star, ShoppingCart } from 'lucide-react'
|
|
8
|
+
|
|
9
|
+
export interface Product {
|
|
10
|
+
id: string | number
|
|
11
|
+
name: string
|
|
12
|
+
description?: string
|
|
13
|
+
price: number
|
|
14
|
+
originalPrice?: number
|
|
15
|
+
currency?: string
|
|
16
|
+
image: string
|
|
17
|
+
rating?: number
|
|
18
|
+
reviewCount?: number
|
|
19
|
+
badge?: string
|
|
20
|
+
inStock?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ProductGridProps extends React.ComponentPropsWithoutRef<'section'> {
|
|
24
|
+
products: Product[]
|
|
25
|
+
columns?: 2 | 3 | 4
|
|
26
|
+
onAddToCart?: (product: Product) => void
|
|
27
|
+
onProductClick?: (product: Product) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ProductGrid({
|
|
31
|
+
className,
|
|
32
|
+
products,
|
|
33
|
+
columns = 4,
|
|
34
|
+
onAddToCart,
|
|
35
|
+
onProductClick,
|
|
36
|
+
...props
|
|
37
|
+
}: ProductGridProps) {
|
|
38
|
+
const gridCols = {
|
|
39
|
+
2: 'sm:grid-cols-2',
|
|
40
|
+
3: 'sm:grid-cols-2 lg:grid-cols-3',
|
|
41
|
+
4: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
42
|
+
}[columns]
|
|
43
|
+
|
|
44
|
+
const formatPrice = (price: number, currency = '$') => {
|
|
45
|
+
return `${currency}${price.toFixed(2)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const calculateDiscount = (price: number, originalPrice: number) => {
|
|
49
|
+
return Math.round(((originalPrice - price) / originalPrice) * 100)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section className={cn('py-8', className)} {...props}>
|
|
54
|
+
<div className="container">
|
|
55
|
+
<div className={cn('grid gap-6', gridCols)}>
|
|
56
|
+
{products.map((product) => (
|
|
57
|
+
<Card
|
|
58
|
+
key={product.id}
|
|
59
|
+
className="group relative overflow-hidden"
|
|
60
|
+
>
|
|
61
|
+
{product.badge && (
|
|
62
|
+
<Badge className="absolute left-2 top-2 z-10">
|
|
63
|
+
{product.badge}
|
|
64
|
+
</Badge>
|
|
65
|
+
)}
|
|
66
|
+
{product.originalPrice && product.originalPrice > product.price && (
|
|
67
|
+
<Badge
|
|
68
|
+
variant="destructive"
|
|
69
|
+
className="absolute right-2 top-2 z-10"
|
|
70
|
+
>
|
|
71
|
+
-{calculateDiscount(product.price, product.originalPrice)}%
|
|
72
|
+
</Badge>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<div
|
|
76
|
+
className="aspect-square cursor-pointer overflow-hidden"
|
|
77
|
+
onClick={() => onProductClick?.(product)}
|
|
78
|
+
>
|
|
79
|
+
<img
|
|
80
|
+
src={product.image}
|
|
81
|
+
alt={product.name}
|
|
82
|
+
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<CardContent className="p-4">
|
|
87
|
+
<h3
|
|
88
|
+
className="cursor-pointer font-semibold hover:underline"
|
|
89
|
+
onClick={() => onProductClick?.(product)}
|
|
90
|
+
>
|
|
91
|
+
{product.name}
|
|
92
|
+
</h3>
|
|
93
|
+
|
|
94
|
+
{product.description && (
|
|
95
|
+
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
|
96
|
+
{product.description}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{product.rating !== undefined && (
|
|
101
|
+
<div className="mt-2 flex items-center gap-2">
|
|
102
|
+
<div className="flex items-center gap-1">
|
|
103
|
+
<Star className="h-4 w-4 fill-primary text-primary" />
|
|
104
|
+
<span className="text-sm font-medium">
|
|
105
|
+
{product.rating.toFixed(1)}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
{product.reviewCount !== undefined && (
|
|
109
|
+
<span className="text-sm text-muted-foreground">
|
|
110
|
+
({product.reviewCount})
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="mt-3 flex items-center gap-2">
|
|
117
|
+
<span className="text-lg font-bold">
|
|
118
|
+
{formatPrice(product.price, product.currency)}
|
|
119
|
+
</span>
|
|
120
|
+
{product.originalPrice && product.originalPrice > product.price && (
|
|
121
|
+
<span className="text-sm text-muted-foreground line-through">
|
|
122
|
+
{formatPrice(product.originalPrice, product.currency)}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{product.inStock === false && (
|
|
128
|
+
<p className="mt-2 text-sm text-destructive">Out of stock</p>
|
|
129
|
+
)}
|
|
130
|
+
</CardContent>
|
|
131
|
+
|
|
132
|
+
<CardFooter className="p-4 pt-0">
|
|
133
|
+
<Button
|
|
134
|
+
className="w-full"
|
|
135
|
+
disabled={product.inStock === false}
|
|
136
|
+
onClick={() => onAddToCart?.(product)}
|
|
137
|
+
>
|
|
138
|
+
<ShoppingCart className="mr-2 h-4 w-4" />
|
|
139
|
+
Add to cart
|
|
140
|
+
</Button>
|
|
141
|
+
</CardFooter>
|
|
142
|
+
</Card>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@hanzo/ui/util'
|
|
4
|
+
import { Button } from '@hanzo/ui/primitives'
|
|
5
|
+
import { Input } from '@hanzo/ui/primitives'
|
|
6
|
+
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@hanzo/ui/primitives'
|
|
7
|
+
import { Separator } from '@hanzo/ui/primitives'
|
|
8
|
+
import { Trash2, Plus, Minus } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
export interface CartItem {
|
|
11
|
+
id: string | number
|
|
12
|
+
name: string
|
|
13
|
+
price: number
|
|
14
|
+
quantity: number
|
|
15
|
+
image: string
|
|
16
|
+
variant?: string
|
|
17
|
+
currency?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ShoppingCartProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
21
|
+
items: CartItem[]
|
|
22
|
+
onUpdateQuantity?: (id: string | number, quantity: number) => void
|
|
23
|
+
onRemoveItem?: (id: string | number) => void
|
|
24
|
+
onCheckout?: () => void
|
|
25
|
+
shippingCost?: number
|
|
26
|
+
taxRate?: number
|
|
27
|
+
currency?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ShoppingCart({
|
|
31
|
+
className,
|
|
32
|
+
items,
|
|
33
|
+
onUpdateQuantity,
|
|
34
|
+
onRemoveItem,
|
|
35
|
+
onCheckout,
|
|
36
|
+
shippingCost = 0,
|
|
37
|
+
taxRate = 0,
|
|
38
|
+
currency = '$',
|
|
39
|
+
...props
|
|
40
|
+
}: ShoppingCartProps) {
|
|
41
|
+
const formatPrice = (price: number) => {
|
|
42
|
+
return `${currency}${price.toFixed(2)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
46
|
+
const tax = subtotal * taxRate
|
|
47
|
+
const total = subtotal + tax + shippingCost
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn('grid gap-6 lg:grid-cols-3', className)} {...props}>
|
|
51
|
+
<div className="lg:col-span-2">
|
|
52
|
+
<Card>
|
|
53
|
+
<CardHeader>
|
|
54
|
+
<CardTitle>Shopping Cart ({items.length} items)</CardTitle>
|
|
55
|
+
</CardHeader>
|
|
56
|
+
<CardContent className="p-0">
|
|
57
|
+
{items.length === 0 ? (
|
|
58
|
+
<div className="p-6 text-center text-muted-foreground">
|
|
59
|
+
Your cart is empty
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<div className="divide-y">
|
|
63
|
+
{items.map((item) => (
|
|
64
|
+
<div key={item.id} className="flex gap-4 p-6">
|
|
65
|
+
<div className="h-24 w-24 flex-shrink-0 overflow-hidden rounded-lg">
|
|
66
|
+
<img
|
|
67
|
+
src={item.image}
|
|
68
|
+
alt={item.name}
|
|
69
|
+
className="h-full w-full object-cover"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="flex flex-1 flex-col">
|
|
73
|
+
<div className="flex justify-between">
|
|
74
|
+
<div>
|
|
75
|
+
<h3 className="font-semibold">{item.name}</h3>
|
|
76
|
+
{item.variant && (
|
|
77
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
78
|
+
{item.variant}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
<p className="font-semibold">
|
|
83
|
+
{formatPrice(item.price * item.quantity)}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="mt-4 flex items-center justify-between">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<Button
|
|
89
|
+
size="icon"
|
|
90
|
+
variant="outline"
|
|
91
|
+
className="h-8 w-8"
|
|
92
|
+
onClick={() =>
|
|
93
|
+
onUpdateQuantity?.(item.id, Math.max(1, item.quantity - 1))
|
|
94
|
+
}
|
|
95
|
+
disabled={item.quantity <= 1}
|
|
96
|
+
>
|
|
97
|
+
<Minus className="h-4 w-4" />
|
|
98
|
+
</Button>
|
|
99
|
+
<Input
|
|
100
|
+
type="number"
|
|
101
|
+
value={item.quantity}
|
|
102
|
+
onChange={(e) => {
|
|
103
|
+
const value = parseInt(e.target.value, 10)
|
|
104
|
+
if (!isNaN(value) && value > 0) {
|
|
105
|
+
onUpdateQuantity?.(item.id, value)
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
className="h-8 w-16 text-center"
|
|
109
|
+
min="1"
|
|
110
|
+
/>
|
|
111
|
+
<Button
|
|
112
|
+
size="icon"
|
|
113
|
+
variant="outline"
|
|
114
|
+
className="h-8 w-8"
|
|
115
|
+
onClick={() =>
|
|
116
|
+
onUpdateQuantity?.(item.id, item.quantity + 1)
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
<Plus className="h-4 w-4" />
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
<Button
|
|
123
|
+
size="sm"
|
|
124
|
+
variant="ghost"
|
|
125
|
+
onClick={() => onRemoveItem?.(item.id)}
|
|
126
|
+
>
|
|
127
|
+
<Trash2 className="h-4 w-4" />
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</CardContent>
|
|
136
|
+
</Card>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div>
|
|
140
|
+
<Card>
|
|
141
|
+
<CardHeader>
|
|
142
|
+
<CardTitle>Order Summary</CardTitle>
|
|
143
|
+
</CardHeader>
|
|
144
|
+
<CardContent className="space-y-4">
|
|
145
|
+
<div className="flex justify-between">
|
|
146
|
+
<span>Subtotal</span>
|
|
147
|
+
<span>{formatPrice(subtotal)}</span>
|
|
148
|
+
</div>
|
|
149
|
+
{shippingCost > 0 && (
|
|
150
|
+
<div className="flex justify-between">
|
|
151
|
+
<span>Shipping</span>
|
|
152
|
+
<span>{formatPrice(shippingCost)}</span>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
{taxRate > 0 && (
|
|
156
|
+
<div className="flex justify-between">
|
|
157
|
+
<span>Tax</span>
|
|
158
|
+
<span>{formatPrice(tax)}</span>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
<Separator />
|
|
162
|
+
<div className="flex justify-between text-lg font-semibold">
|
|
163
|
+
<span>Total</span>
|
|
164
|
+
<span>{formatPrice(total)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
</CardContent>
|
|
167
|
+
<CardFooter>
|
|
168
|
+
<Button
|
|
169
|
+
className="w-full"
|
|
170
|
+
size="lg"
|
|
171
|
+
onClick={onCheckout}
|
|
172
|
+
disabled={items.length === 0}
|
|
173
|
+
>
|
|
174
|
+
Proceed to Checkout
|
|
175
|
+
</Button>
|
|
176
|
+
</CardFooter>
|
|
177
|
+
</Card>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|