ShadcnUI Vaults

Back to Blocks

Add-Ons Selection Hub

Unknown Block

UnknownComponent

Add-Ons Selection Hub

Comprehensive add-ons marketplace with quantity controls and total cost calculation.

Preview

Full width desktop view

Code

marketplace-3.tsx
1"use client";
2
3import * as React from "react";
4import { Button } from "@/components/ui/button";
5import {
6  Card,
7  CardContent,
8  CardDescription,
9  CardFooter,
10  CardHeader,
11  CardTitle,
12} from "@/components/ui/card";
13import { Badge } from "@/components/ui/badge";
14import { Separator } from "@/components/ui/separator";
15import {
16  Plus,
17  Minus,
18  ShoppingCart,
19  Check,
20  Star,
21  Globe,
22  Palette,
23  Shield,
24  Zap,
25} from "lucide-react";
26
27interface AddOn {
28  id: string;
29  name: string;
30  description: string;
31  price: number;
32  period: string;
33  icon: React.ReactNode;
34  category: string;
35  isMultiple: boolean;
36  maxQuantity?: number;
37  popular?: boolean;
38  features: string[];
39}
40
41interface SelectedAddOn {
42  id: string;
43  quantity: number;
44}
45
46interface AddOnMarketplaceProps {
47  addOns?: AddOn[];
48  onSelectionChange?: (selectedAddOns: SelectedAddOn[]) => void;
49}
50
51const defaultAddOns: AddOn[] = [
52  {
53    id: "extra-pages",
54    name: "Extra Pages",
55    description: "Add additional pages to your website with full customization",
56    price: 10,
57    period: "month",
58    icon: <Plus className="h-5 w-5" />,
59    category: "Content",
60    isMultiple: true,
61    maxQuantity: 50,
62    features: ["Full customization", "SEO optimized", "Mobile responsive"],
63  },
64  {
65    id: "custom-domain",
66    name: "Custom Domain",
67    description: "Connect your own domain name to your website",
68    price: 15,
69    period: "month",
70    icon: <Globe className="h-5 w-5" />,
71    category: "Domain",
72    isMultiple: false,
73    popular: true,
74    features: [
75      "SSL certificate included",
76      "DNS management",
77      "Email forwarding",
78    ],
79  },
80  {
81    id: "premium-templates",
82    name: "Premium Templates",
83    description: "Access to exclusive premium design templates",
84    price: 25,
85    period: "month",
86    icon: <Palette className="h-5 w-5" />,
87    category: "Design",
88    isMultiple: false,
89    features: ["50+ premium templates", "Regular updates", "Priority support"],
90  },
91  {
92    id: "advanced-analytics",
93    name: "Advanced Analytics",
94    description: "Detailed insights and analytics for your website",
95    price: 20,
96    period: "month",
97    icon: <Star className="h-5 w-5" />,
98    category: "Analytics",
99    isMultiple: false,
100    features: ["Real-time data", "Custom reports", "Goal tracking"],
101  },
102  {
103    id: "ssl-certificates",
104    name: "SSL Certificates",
105    description: "Additional SSL certificates for subdomains",
106    price: 5,
107    period: "month",
108    icon: <Shield className="h-5 w-5" />,
109    category: "Security",
110    isMultiple: true,
111    maxQuantity: 10,
112    features: ["256-bit encryption", "Wildcard support", "Auto-renewal"],
113  },
114  {
115    id: "performance-boost",
116    name: "Performance Boost",
117    description: "Enhanced server resources and CDN acceleration",
118    price: 30,
119    period: "month",
120    icon: <Zap className="h-5 w-5" />,
121    category: "Performance",
122    isMultiple: false,
123    popular: true,
124    features: ["Global CDN", "SSD storage", "99.9% uptime"],
125  },
126];
127
128const AddOnMarketplace: React.FC<AddOnMarketplaceProps> = ({
129  addOns = defaultAddOns,
130  onSelectionChange,
131}) => {
132  const [selectedAddOns, setSelectedAddOns] = React.useState<SelectedAddOn[]>(
133    []
134  );
135
136  const updateSelection = (addOnId: string, quantity: number) => {
137    const newSelection = selectedAddOns.filter((item) => item.id !== addOnId);
138    if (quantity > 0) {
139      newSelection.push({ id: addOnId, quantity });
140    }
141    setSelectedAddOns(newSelection);
142    onSelectionChange?.(newSelection);
143  };
144
145  const getQuantity = (addOnId: string) => {
146    return selectedAddOns.find((item) => item.id === addOnId)?.quantity || 0;
147  };
148
149  const increaseQuantity = (addOn: AddOn) => {
150    const currentQuantity = getQuantity(addOn.id);
151    const maxQty = addOn.maxQuantity || 999;
152    if (currentQuantity < maxQty) {
153      updateSelection(addOn.id, currentQuantity + 1);
154    }
155  };
156
157  const decreaseQuantity = (addOn: AddOn) => {
158    const currentQuantity = getQuantity(addOn.id);
159    if (currentQuantity > 0) {
160      updateSelection(addOn.id, currentQuantity - 1);
161    }
162  };
163
164  const toggleSingleAddOn = (addOn: AddOn) => {
165    const currentQuantity = getQuantity(addOn.id);
166    updateSelection(addOn.id, currentQuantity > 0 ? 0 : 1);
167  };
168
169  const getTotalCost = () => {
170    return selectedAddOns.reduce((total, selected) => {
171      const addOn = addOns.find((a) => a.id === selected.id);
172      return total + (addOn ? addOn.price * selected.quantity : 0);
173    }, 0);
174  };
175
176  const categories = Array.from(new Set(addOns.map((addOn) => addOn.category)));
177
178  return (
179    <div className="w-full max-w-6xl mx-auto p-6 space-y-8">
180      <div className="text-center space-y-4">
181        <h1 className="text-4xl font-bold text-foreground">
182          Add-Ons Marketplace
183        </h1>
184        <p className="text-lg text-muted-foreground max-w-2xl mx-auto">
185          Enhance your experience with our premium add-ons. Choose from a
186          variety of features to customize your plan.
187        </p>
188      </div>
189
190      {categories.map((category) => (
191        <div key={category} className="space-y-4">
192          <h2 className="text-2xl font-semibold text-foreground">{category}</h2>
193          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
194            {addOns
195              .filter((addOn) => addOn.category === category)
196              .map((addOn) => {
197                const quantity = getQuantity(addOn.id);
198                const isSelected = quantity > 0;
199
200                return (
201                  <Card
202                    key={addOn.id}
203                    className={`relative transition-all duration-200 ${
204                      isSelected
205                        ? "ring-2 ring-primary shadow-lg"
206                        : "hover:shadow-md"
207                    }`}
208                  >
209                    {addOn.popular && (
210                      <Badge className="absolute -top-2 left-4 bg-primary text-primary-foreground">
211                        Popular
212                      </Badge>
213                    )}
214
215                    <CardHeader className="pb-4">
216                      <div className="flex items-center gap-3">
217                        <div className="p-2 rounded-lg bg-primary/10 text-primary">
218                          {addOn.icon}
219                        </div>
220                        <div className="flex-1">
221                          <CardTitle className="text-lg">
222                            {addOn.name}
223                          </CardTitle>
224                          <div className="flex items-center gap-2 mt-1">
225                            <span className="text-2xl font-bold text-foreground">
226                              ${addOn.price}
227                            </span>
228                            <span className="text-sm text-muted-foreground">
229                              /{addOn.period}
230                            </span>
231                            {addOn.isMultiple && (
232                              <Badge variant="outline" className="text-xs">
233                                per{" "}
234                                {addOn.name.toLowerCase().includes("page")
235                                  ? "page"
236                                  : "item"}
237                              </Badge>
238                            )}
239                          </div>
240                        </div>
241                      </div>
242                      <CardDescription className="text-sm">
243                        {addOn.description}
244                      </CardDescription>
245                    </CardHeader>
246
247                    <CardContent className="space-y-4">
248                      <div className="space-y-2">
249                        {addOn.features.map((feature, index) => (
250                          <div
251                            key={index}
252                            className="flex items-center gap-2 text-sm"
253                          >
254                            <Check className="h-4 w-4 text-green-500 flex-shrink-0" />
255                            <span className="text-muted-foreground">
256                              {feature}
257                            </span>
258                          </div>
259                        ))}
260                      </div>
261
262                      {isSelected && (
263                        <>
264                          <Separator />
265                          <div className="bg-primary/5 p-3 rounded-lg">
266                            <div className="flex justify-between items-center text-sm">
267                              <span className="text-muted-foreground">
268                                {addOn.isMultiple
269                                  ? `Quantity: ${quantity}`
270                                  : "Added to cart"}
271                              </span>
272                              <span className="font-semibold text-foreground">
273                                ${addOn.price * quantity}/{addOn.period}
274                              </span>
275                            </div>
276                          </div>
277                        </>
278                      )}
279                    </CardContent>
280
281                    <CardFooter className="pt-4">
282                      {addOn.isMultiple ? (
283                        <div className="flex items-center justify-between w-full">
284                          <div className="flex items-center gap-2">
285                            <Button
286                              variant="outline"
287                              size="sm"
288                              onClick={() => decreaseQuantity(addOn)}
289                              disabled={quantity === 0}
290                              className="h-8 w-8 p-0"
291                            >
292                              <Minus className="h-4 w-4" />
293                            </Button>
294                            <span className="w-8 text-center font-medium">
295                              {quantity}
296                            </span>
297                            <Button
298                              variant="outline"
299                              size="sm"
300                              onClick={() => increaseQuantity(addOn)}
301                              disabled={quantity >= (addOn.maxQuantity || 999)}
302                              className="h-8 w-8 p-0"
303                            >
304                              <Plus className="h-4 w-4" />
305                            </Button>
306                          </div>
307                          <Button
308                            variant={isSelected ? "secondary" : "default"}
309                            size="sm"
310                            onClick={() => increaseQuantity(addOn)}
311                            disabled={quantity >= (addOn.maxQuantity || 999)}
312                          >
313                            {isSelected ? "Added" : "Add"}
314                          </Button>
315                        </div>
316                      ) : (
317                        <Button
318                          variant={isSelected ? "secondary" : "default"}
319                          className="w-full"
320                          onClick={() => toggleSingleAddOn(addOn)}
321                        >
322                          {isSelected ? (
323                            <>
324                              <Check className="h-4 w-4 mr-2" />
325                              Added
326                            </>
327                          ) : (
328                            <>
329                              <ShoppingCart className="h-4 w-4 mr-2" />
330                              Add to Cart
331                            </>
332                          )}
333                        </Button>
334                      )}
335                    </CardFooter>
336                  </Card>
337                );
338              })}
339          </div>
340        </div>
341      ))}
342
343      {selectedAddOns.length > 0 && (
344        <Card className="sticky bottom-6 bg-background/95 backdrop-blur-sm border-2">
345          <CardHeader>
346            <CardTitle className="flex items-center justify-between">
347              <span>Cart Summary</span>
348              <Badge variant="secondary">{selectedAddOns.length} items</Badge>
349            </CardTitle>
350          </CardHeader>
351          <CardContent>
352            <div className="space-y-3">
353              {selectedAddOns.map((selected) => {
354                const addOn = addOns.find((a) => a.id === selected.id);
355                if (!addOn) return null;
356
357                return (
358                  <div
359                    key={selected.id}
360                    className="flex justify-between items-center"
361                  >
362                    <div className="flex items-center gap-3">
363                      <div className="p-1 rounded bg-primary/10 text-primary">
364                        {addOn.icon}
365                      </div>
366                      <div>
367                        <span className="font-medium">{addOn.name}</span>
368                        {selected.quantity > 1 && (
369                          <span className="text-sm text-muted-foreground ml-2">
370                            × {selected.quantity}
371                          </span>
372                        )}
373                      </div>
374                    </div>
375                    <span className="font-semibold">
376                      ${addOn.price * selected.quantity}/{addOn.period}
377                    </span>
378                  </div>
379                );
380              })}
381              <Separator />
382              <div className="flex justify-between items-center text-lg font-bold">
383                <span>Total</span>
384                <span>${getTotalCost()}/month</span>
385              </div>
386            </div>
387          </CardContent>
388          <CardFooter>
389            <Button className="w-full" size="lg">
390              <ShoppingCart className="h-4 w-4 mr-2" />
391              Proceed to Checkout
392            </Button>
393          </CardFooter>
394        </Card>
395      )}
396    </div>
397  );
398};
399
400export default function Demo() {
401  return (
402    <div className="min-h-screen bg-background">
403      <AddOnMarketplace />
404    </div>
405  );
406}

Dependencies

External Libraries

lucide-reactreact

Shadcn/UI Components

badgebuttoncardseparator

LICENSE

MIT License

Copyright (c) 2025 Aldhaneka

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Shadcn Vaults Project (CC BY-NC 4.0 with Internal Use Exception)

All user-submitted components in the '/blocks' directory are licensed under the Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0),
with the following clarification and exception:

You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material

Under these conditions:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- NonCommercial — You may NOT use the material for commercial redistribution, resale, or monetization.

🚫 You MAY NOT:
- Sell or redistribute the components individually or as part of a product (e.g. a UI kit, template marketplace, SaaS component library)
- Offer the components or derivative works in any paid tool, theme pack, or design system

✅ You MAY:
- Use the components in internal company tools, dashboards, or applications that are not sold as products
- Remix or adapt components for private or enterprise projects
- Use them in open-source non-commercial projects

This license encourages sharing, learning, and internal innovation — but prohibits using these components as a basis for monetized products.

Full license text: https://creativecommons.org/licenses/by-nc/4.0/

By submitting a component, contributors agree to these terms.

Contributors

Ramiro Godoy@milogodoy

Review Form Block

Aldhaneka@Aldhanekaa

Project Creator

For questions about licensing, please contact the project maintainers.