Preview
Full width desktop view
Code
marketplace-1.tsx
1"use client";
2
3import { useState } from "react";
4import { motion, AnimatePresence } from "framer-motion";
5import { Minus, Plus, ShoppingCart, X, CreditCard, Check } from "lucide-react";
6import { Button } from "@/components/ui/button";
7import { cn } from "@/lib/utils";
8
9interface AddOn {
10 id: string;
11 name: string;
12 description: string;
13 price: number;
14 period: string;
15 category: string;
16 isMultiple: boolean;
17 maxQuantity?: number;
18 icon?: string;
19}
20
21interface CartItem extends AddOn {
22 quantity: number;
23}
24
25interface AddOnsMarketplaceProps {
26 addOns?: AddOn[];
27}
28
29const defaultAddOns: AddOn[] = [
30 {
31 id: "1",
32 name: "Extra Page",
33 description: "Add additional pages to your website",
34 price: 10,
35 period: "month",
36 category: "Content",
37 isMultiple: true,
38 maxQuantity: 50,
39 icon: "📄",
40 },
41 {
42 id: "2",
43 name: "Custom Domain",
44 description: "Connect your own domain name",
45 price: 15,
46 period: "month",
47 category: "Domain",
48 isMultiple: false,
49 icon: "🌐",
50 },
51 {
52 id: "3",
53 name: "SSL Certificate",
54 description: "Secure your website with SSL encryption",
55 price: 5,
56 period: "month",
57 category: "Security",
58 isMultiple: false,
59 icon: "🔒",
60 },
61 {
62 id: "4",
63 name: "Email Account",
64 description: "Professional email accounts for your domain",
65 price: 8,
66 period: "month",
67 category: "Email",
68 isMultiple: true,
69 maxQuantity: 20,
70 icon: "📧",
71 },
72 {
73 id: "5",
74 name: "Analytics Pro",
75 description: "Advanced analytics and reporting features",
76 price: 25,
77 period: "month",
78 category: "Analytics",
79 isMultiple: false,
80 icon: "📊",
81 },
82 {
83 id: "6",
84 name: "Storage Upgrade",
85 description: "Additional 10GB storage space",
86 price: 3,
87 period: "month",
88 category: "Storage",
89 isMultiple: true,
90 maxQuantity: 100,
91 icon: "💾",
92 },
93 {
94 id: "7",
95 name: "Priority Support",
96 description: "24/7 priority customer support",
97 price: 20,
98 period: "month",
99 category: "Support",
100 isMultiple: false,
101 icon: "🎧",
102 },
103 {
104 id: "8",
105 name: "Backup Service",
106 description: "Daily automated backups",
107 price: 12,
108 period: "month",
109 category: "Security",
110 isMultiple: false,
111 icon: "💿",
112 },
113];
114
115function AddOnsMarketplace({ addOns = defaultAddOns }: AddOnsMarketplaceProps) {
116 const [cart, setCart] = useState<CartItem[]>([]);
117 const [selectedCategory, setSelectedCategory] = useState<string>("All");
118
119 const categories = [
120 "All",
121 ...Array.from(new Set(addOns.map((addon) => addon.category))),
122 ];
123
124 const filteredAddOns =
125 selectedCategory === "All"
126 ? addOns
127 : addOns.filter((addon) => addon.category === selectedCategory);
128
129 const addToCart = (addOn: AddOn) => {
130 setCart((currentCart) => {
131 const existingItem = currentCart.find((item) => item.id === addOn.id);
132
133 if (existingItem) {
134 if (
135 addOn.isMultiple &&
136 (!addOn.maxQuantity || existingItem.quantity < addOn.maxQuantity)
137 ) {
138 return currentCart.map((item) =>
139 item.id === addOn.id
140 ? { ...item, quantity: item.quantity + 1 }
141 : item
142 );
143 }
144 return currentCart;
145 }
146
147 return [...currentCart, { ...addOn, quantity: 1 }];
148 });
149 };
150
151 const removeFromCart = (addOnId: string) => {
152 setCart((currentCart) => currentCart.filter((item) => item.id !== addOnId));
153 };
154
155 const updateQuantity = (addOnId: string, delta: number) => {
156 setCart((currentCart) =>
157 currentCart.map((item) => {
158 if (item.id === addOnId) {
159 const newQuantity = item.quantity + delta;
160 if (newQuantity <= 0) return item;
161 if (item.maxQuantity && newQuantity > item.maxQuantity) return item;
162 return { ...item, quantity: newQuantity };
163 }
164 return item;
165 })
166 );
167 };
168
169 const getCartItem = (addOnId: string) => {
170 return cart.find((item) => item.id === addOnId);
171 };
172
173 const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
174 const totalPrice = cart.reduce(
175 (sum, item) => sum + item.price * item.quantity,
176 0
177 );
178
179 return (
180 <div className="w-full max-w-7xl mx-auto p-6">
181 <div className="mb-8">
182 <h1 className="text-3xl font-bold text-foreground mb-2">
183 Add-Ons Marketplace
184 </h1>
185 <p className="text-muted-foreground">
186 Enhance your plan with powerful add-ons
187 </p>
188 </div>
189
190 <div className="flex gap-8">
191 <div className="flex-1">
192 {/* Category Filter */}
193 <div className="mb-6">
194 <div className="flex flex-wrap gap-2">
195 {categories.map((category) => (
196 <Button
197 key={category}
198 variant={
199 selectedCategory === category ? "default" : "outline"
200 }
201 size="sm"
202 onClick={() => setSelectedCategory(category)}
203 className="text-xs"
204 >
205 {category}
206 </Button>
207 ))}
208 </div>
209 </div>
210
211 {/* Add-Ons Grid */}
212 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
213 {filteredAddOns.map((addOn) => {
214 const cartItem = getCartItem(addOn.id);
215 const isInCart = !!cartItem;
216 const canAddMore =
217 addOn.isMultiple &&
218 (!addOn.maxQuantity ||
219 !cartItem ||
220 cartItem.quantity < addOn.maxQuantity);
221
222 return (
223 <motion.div
224 key={addOn.id}
225 initial={{ opacity: 0, y: 20 }}
226 animate={{ opacity: 1, y: 0 }}
227 transition={{ duration: 0.3 }}
228 className={cn(
229 "group relative p-6 rounded-xl border transition-all duration-200",
230 "bg-background hover:bg-accent/50",
231 "border-border hover:border-border/80",
232 isInCart && "ring-2 ring-primary/20 border-primary/30"
233 )}
234 >
235 <div className="flex items-start justify-between mb-4">
236 <div className="flex items-center gap-3">
237 <div className="text-2xl">{addOn.icon}</div>
238 <div>
239 <h3 className="font-semibold text-foreground">
240 {addOn.name}
241 </h3>
242 <span className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground">
243 {addOn.category}
244 </span>
245 </div>
246 </div>
247 {!addOn.isMultiple && isInCart && (
248 <Check className="w-5 h-5 text-primary" />
249 )}
250 </div>
251
252 <p className="text-sm text-muted-foreground mb-4 line-clamp-2">
253 {addOn.description}
254 </p>
255
256 <div className="flex items-center justify-between">
257 <div className="flex flex-col">
258 <span className="text-lg font-bold text-foreground">
259 ${addOn.price}
260 </span>
261 <span className="text-xs text-muted-foreground">
262 per {addOn.period}
263 {addOn.isMultiple && " (each)"}
264 </span>
265 </div>
266
267 <div className="flex items-center gap-2">
268 {isInCart && addOn.isMultiple && (
269 <div className="flex items-center gap-1 bg-secondary rounded-lg p-1">
270 <Button
271 size="sm"
272 variant="ghost"
273 className="h-6 w-6 p-0"
274 onClick={() => updateQuantity(addOn.id, -1)}
275 >
276 <Minus className="w-3 h-3" />
277 </Button>
278 <span className="text-sm font-medium w-6 text-center">
279 {cartItem?.quantity}
280 </span>
281 <Button
282 size="sm"
283 variant="ghost"
284 className="h-6 w-6 p-0"
285 onClick={() => updateQuantity(addOn.id, 1)}
286 disabled={!canAddMore}
287 >
288 <Plus className="w-3 h-3" />
289 </Button>
290 </div>
291 )}
292
293 {!isInCart && (
294 <Button
295 size="sm"
296 onClick={() => addToCart(addOn)}
297 className="gap-1"
298 >
299 <Plus className="w-3 h-3" />
300 Add
301 </Button>
302 )}
303
304 {isInCart && !addOn.isMultiple && (
305 <Button
306 size="sm"
307 variant="outline"
308 onClick={() => removeFromCart(addOn.id)}
309 className="gap-1"
310 >
311 <X className="w-3 h-3" />
312 Remove
313 </Button>
314 )}
315
316 {isInCart && addOn.isMultiple && canAddMore && (
317 <Button
318 size="sm"
319 variant="outline"
320 onClick={() => addToCart(addOn)}
321 className="gap-1"
322 >
323 <Plus className="w-3 h-3" />
324 More
325 </Button>
326 )}
327 </div>
328 </div>
329
330 {addOn.maxQuantity && addOn.isMultiple && (
331 <div className="mt-2 text-xs text-muted-foreground">
332 Max: {addOn.maxQuantity}{" "}
333 {addOn.maxQuantity === 1 ? "unit" : "units"}
334 </div>
335 )}
336 </motion.div>
337 );
338 })}
339 </div>
340 </div>
341
342 {/* Cart Sidebar */}
343 <motion.div
344 initial={{ opacity: 0, x: 20 }}
345 animate={{ opacity: 1, x: 0 }}
346 className={cn(
347 "w-80 flex flex-col",
348 "p-6 rounded-xl",
349 "bg-background border border-border",
350 "sticky top-6 max-h-[calc(100vh-3rem)]"
351 )}
352 >
353 <div className="flex items-center gap-2 mb-4">
354 <ShoppingCart className="w-5 h-5 text-muted-foreground" />
355 <h2 className="text-lg font-semibold text-foreground">
356 Cart ({totalItems})
357 </h2>
358 </div>
359
360 <div className="flex-1 overflow-y-auto space-y-3 min-h-0">
361 <AnimatePresence initial={false} mode="popLayout">
362 {cart.length === 0 ? (
363 <motion.div
364 initial={{ opacity: 0 }}
365 animate={{ opacity: 1 }}
366 className="text-center py-8 text-muted-foreground"
367 >
368 <ShoppingCart className="w-12 h-12 mx-auto mb-3 opacity-50" />
369 <p>Your cart is empty</p>
370 <p className="text-sm">Add some add-ons to get started</p>
371 </motion.div>
372 ) : (
373 cart.map((item) => (
374 <motion.div
375 key={item.id}
376 layout
377 initial={{ opacity: 0, scale: 0.96 }}
378 animate={{ opacity: 1, scale: 1 }}
379 exit={{ opacity: 0, scale: 0.96 }}
380 transition={{ duration: 0.2 }}
381 className="p-4 rounded-lg bg-secondary/50 border border-border/50"
382 >
383 <div className="flex items-start justify-between mb-2">
384 <div className="flex-1 min-w-0">
385 <h4 className="font-medium text-foreground truncate">
386 {item.name}
387 </h4>
388 <p className="text-xs text-muted-foreground">
389 ${item.price}/{item.period}
390 </p>
391 </div>
392 <Button
393 size="sm"
394 variant="ghost"
395 className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
396 onClick={() => removeFromCart(item.id)}
397 >
398 <X className="w-3 h-3" />
399 </Button>
400 </div>
401
402 <div className="flex items-center justify-between">
403 {item.isMultiple ? (
404 <div className="flex items-center gap-1">
405 <Button
406 size="sm"
407 variant="outline"
408 className="h-6 w-6 p-0"
409 onClick={() => updateQuantity(item.id, -1)}
410 >
411 <Minus className="w-3 h-3" />
412 </Button>
413 <span className="text-sm font-medium w-8 text-center">
414 {item.quantity}
415 </span>
416 <Button
417 size="sm"
418 variant="outline"
419 className="h-6 w-6 p-0"
420 onClick={() => updateQuantity(item.id, 1)}
421 disabled={
422 item.maxQuantity
423 ? item.quantity >= item.maxQuantity
424 : false
425 }
426 >
427 <Plus className="w-3 h-3" />
428 </Button>
429 </div>
430 ) : (
431 <span className="text-sm text-muted-foreground">
432 Single use
433 </span>
434 )}
435
436 <span className="text-sm font-semibold text-foreground">
437 ${(item.price * item.quantity).toFixed(2)}
438 </span>
439 </div>
440 </motion.div>
441 ))
442 )}
443 </AnimatePresence>
444 </div>
445
446 {cart.length > 0 && (
447 <motion.div layout className="pt-4 mt-4 border-t border-border">
448 <div className="flex items-center justify-between mb-4">
449 <span className="text-lg font-semibold text-foreground">
450 Total
451 </span>
452 <span className="text-xl font-bold text-foreground">
453 ${totalPrice.toFixed(2)}/month
454 </span>
455 </div>
456 <Button className="w-full gap-2">
457 <CreditCard className="w-4 h-4" />
458 Checkout
459 </Button>
460 </motion.div>
461 )}
462 </motion.div>
463 </div>
464 </div>
465 );
466}
467
468export default function AddOnsMarketplaceDemo() {
469 return <AddOnsMarketplace />;
470}
Dependencies
External Libraries
framer-motionlucide-reactreact
Shadcn/UI Components
button
Local Components
/lib/utils