ShadcnUI Vaults

Back to Blocks

Multi-Plan Billing Portal

Unknown Block

UnknownComponent

Multi-Plan Billing Portal

Portal for managing multiple plans, add-ons, and billing cycles.

Preview

Full width desktop view

Code

bills-5.tsx
1"use client";
2
3import * as React from "react";
4import { useState } from "react";
5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6import { Button } from "@/components/ui/button";
7import { Separator } from "@/components/ui/separator";
8import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9import { Badge } from "@/components/ui/badge";
10import { Plus, Minus, CreditCard, Calendar, DollarSign } from "lucide-react";
11import { cn } from "@/lib/utils";
12
13// Types
14interface Plan {
15  id: string;
16  name: string;
17  price: number;
18  period: "monthly" | "yearly";
19  features: string[];
20  popular?: boolean;
21}
22
23interface AddOn {
24  id: string;
25  name: string;
26  description: string;
27  price: number;
28  period: "monthly" | "yearly";
29  type: "single" | "multiple";
30  maxQuantity?: number;
31}
32
33interface SelectedAddOn extends AddOn {
34  quantity: number;
35}
36
37interface Bill {
38  id: string;
39  description: string;
40  amount: number;
41  dueDate: string;
42  status: "pending" | "paid" | "overdue";
43}
44
45// Sample data
46const plans: Plan[] = [
47  {
48    id: "free",
49    name: "Free",
50    price: 0,
51    period: "monthly",
52    features: ["Up to 5 projects", "Basic analytics", "Community support"],
53  },
54  {
55    id: "starter",
56    name: "Starter",
57    price: 29,
58    period: "monthly",
59    features: [
60      "Up to 25 projects",
61      "Advanced analytics",
62      "Email support",
63      "Custom domains",
64    ],
65    popular: true,
66  },
67  {
68    id: "pro",
69    name: "Pro",
70    price: 99,
71    period: "monthly",
72    features: [
73      "Unlimited projects",
74      "Premium analytics",
75      "Priority support",
76      "API access",
77      "White-label",
78    ],
79  },
80];
81
82const addOns: AddOn[] = [
83  {
84    id: "extra-page",
85    name: "Extra Page",
86    description: "Additional page for your website",
87    price: 10,
88    period: "monthly",
89    type: "multiple",
90    maxQuantity: 50,
91  },
92  {
93    id: "custom-domain",
94    name: "Custom Domain",
95    description: "Connect your own domain",
96    price: 15,
97    period: "monthly",
98    type: "single",
99  },
100  {
101    id: "ssl-certificate",
102    name: "SSL Certificate",
103    description: "Secure your website with SSL",
104    price: 5,
105    period: "monthly",
106    type: "single",
107  },
108  {
109    id: "priority-support",
110    name: "Priority Support",
111    description: "Get faster response times",
112    price: 25,
113    period: "monthly",
114    type: "single",
115  },
116];
117
118const sampleBills: Bill[] = [
119  {
120    id: "bill-1",
121    description: "Starter Plan - December 2024",
122    amount: 29,
123    dueDate: "2024-12-31",
124    status: "pending",
125  },
126  {
127    id: "bill-2",
128    description: "Extra Pages (5x) - December 2024",
129    amount: 50,
130    dueDate: "2024-12-31",
131    status: "pending",
132  },
133  {
134    id: "bill-3",
135    description: "Custom Domain - December 2024",
136    amount: 15,
137    dueDate: "2024-12-31",
138    status: "paid",
139  },
140];
141
142// Numeric Input Component
143interface NumericInputProps {
144  min?: number;
145  max?: number;
146  step?: number;
147  value: number;
148  onChange: (value: number) => void;
149  disabled?: boolean;
150}
151
152function NumericInput({
153  min = 0,
154  max = 100,
155  step = 1,
156  value,
157  onChange,
158  disabled = false,
159}: NumericInputProps) {
160  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
161    const newValue = Number.parseFloat(e.target.value);
162    if (!Number.isNaN(newValue) && newValue >= min && newValue <= max) {
163      onChange(newValue);
164    }
165  };
166
167  const incrementValue = () => {
168    const newValue = Math.min(value + step, max);
169    onChange(newValue);
170  };
171
172  const decrementValue = () => {
173    const newValue = Math.max(value - step, min);
174    onChange(newValue);
175  };
176
177  return (
178    <div className="flex items-center border border-border rounded-lg bg-background">
179      <button
180        type="button"
181        onClick={decrementValue}
182        disabled={disabled || value <= min}
183        className="px-3 py-2 hover:bg-muted text-muted-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
184        aria-label="Decrement"
185      >
186        <Minus className="h-4 w-4" />
187      </button>
188      <input
189        type="number"
190        value={value}
191        onChange={handleChange}
192        min={min}
193        max={max}
194        step={step}
195        disabled={disabled}
196        className="w-16 text-center border-none bg-transparent focus:outline-none [-moz-appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-foreground"
197      />
198      <button
199        type="button"
200        onClick={incrementValue}
201        disabled={disabled || value >= max}
202        className="px-3 py-2 hover:bg-muted text-muted-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
203        aria-label="Increment"
204      >
205        <Plus className="h-4 w-4" />
206      </button>
207    </div>
208  );
209}
210
211// Plan Selection Component
212interface PlanSelectorProps {
213  plans: Plan[];
214  selectedPlan: string;
215  onPlanSelect: (planId: string) => void;
216}
217
218function PlanSelector({
219  plans,
220  selectedPlan,
221  onPlanSelect,
222}: PlanSelectorProps) {
223  return (
224    <div className="space-y-4">
225      {plans.map((plan) => (
226        <div
227          key={plan.id}
228          className={cn(
229            "relative cursor-pointer border-2 rounded-lg p-4 transition-all",
230            selectedPlan === plan.id
231              ? "border-primary bg-primary/5"
232              : "border-border hover:border-primary/50"
233          )}
234          onClick={() => onPlanSelect(plan.id)}
235        >
236          <div className="flex items-center justify-between">
237            <div className="flex-1">
238              <div className="flex items-center gap-2">
239                <h3 className="text-lg font-semibold text-foreground">
240                  {plan.name}
241                </h3>
242                {plan.popular && (
243                  <Badge
244                    variant="secondary"
245                    className="bg-yellow-100 text-yellow-800"
246                  >
247                    Popular
248                  </Badge>
249                )}
250              </div>
251              <p className="text-muted-foreground">
252                <span className="text-2xl font-bold text-foreground">
253                  ${plan.price}
254                </span>
255                /{plan.period}
256              </p>
257              <ul className="mt-2 space-y-1 text-sm text-muted-foreground">
258                {plan.features.map((feature, index) => (
259                  <li key={index}>{feature}</li>
260                ))}
261              </ul>
262            </div>
263            <div
264              className={cn(
265                "w-6 h-6 rounded-full border-2 flex items-center justify-center",
266                selectedPlan === plan.id
267                  ? "border-primary bg-primary"
268                  : "border-muted-foreground"
269              )}
270            >
271              {selectedPlan === plan.id && (
272                <div className="w-3 h-3 rounded-full bg-primary-foreground" />
273              )}
274            </div>
275          </div>
276        </div>
277      ))}
278    </div>
279  );
280}
281
282// Add-ons Component
283interface AddOnsProps {
284  addOns: AddOn[];
285  selectedAddOns: SelectedAddOn[];
286  onAddOnChange: (addOnId: string, quantity: number) => void;
287}
288
289function AddOnsSelector({
290  addOns,
291  selectedAddOns,
292  onAddOnChange,
293}: AddOnsProps) {
294  const getSelectedQuantity = (addOnId: string) => {
295    const selected = selectedAddOns.find((addon) => addon.id === addOnId);
296    return selected ? selected.quantity : 0;
297  };
298
299  return (
300    <div className="space-y-4">
301      {addOns.map((addOn) => {
302        const quantity = getSelectedQuantity(addOn.id);
303        const isSelected = quantity > 0;
304
305        return (
306          <div
307            key={addOn.id}
308            className={cn(
309              "border rounded-lg p-4 transition-all",
310              isSelected ? "border-primary bg-primary/5" : "border-border"
311            )}
312          >
313            <div className="flex items-center justify-between">
314              <div className="flex-1">
315                <h4 className="font-medium text-foreground">{addOn.name}</h4>
316                <p className="text-sm text-muted-foreground">
317                  {addOn.description}
318                </p>
319                <p className="text-sm font-medium text-foreground">
320                  ${addOn.price}/{addOn.period}
321                  {addOn.type === "multiple" && " (per unit)"}
322                </p>
323              </div>
324              <div className="flex items-center gap-3">
325                {addOn.type === "single" ? (
326                  <Button
327                    variant={isSelected ? "default" : "outline"}
328                    size="sm"
329                    onClick={() => onAddOnChange(addOn.id, isSelected ? 0 : 1)}
330                  >
331                    {isSelected ? "Remove" : "Add"}
332                  </Button>
333                ) : (
334                  <div className="flex items-center gap-2">
335                    <NumericInput
336                      min={0}
337                      max={addOn.maxQuantity || 50}
338                      value={quantity}
339                      onChange={(value) => onAddOnChange(addOn.id, value)}
340                    />
341                  </div>
342                )}
343              </div>
344            </div>
345          </div>
346        );
347      })}
348    </div>
349  );
350}
351
352// Bills Component
353interface BillsProps {
354  bills: Bill[];
355}
356
357function BillsList({ bills }: BillsProps) {
358  const getStatusColor = (status: Bill["status"]) => {
359    switch (status) {
360      case "paid":
361        return "bg-green-100 text-green-800";
362      case "pending":
363        return "bg-yellow-100 text-yellow-800";
364      case "overdue":
365        return "bg-red-100 text-red-800";
366      default:
367        return "bg-gray-100 text-gray-800";
368    }
369  };
370
371  const totalAmount = bills
372    .filter((bill) => bill.status !== "paid")
373    .reduce((sum, bill) => sum + bill.amount, 0);
374
375  return (
376    <div className="space-y-4">
377      <div className="flex items-center justify-between">
378        <h3 className="text-lg font-semibold text-foreground">Current Bills</h3>
379        <div className="text-right">
380          <p className="text-sm text-muted-foreground">Total Outstanding</p>
381          <p className="text-2xl font-bold text-foreground">${totalAmount}</p>
382        </div>
383      </div>
384
385      <div className="space-y-3">
386        {bills.map((bill) => (
387          <div
388            key={bill.id}
389            className="flex items-center justify-between p-4 border border-border rounded-lg bg-background"
390          >
391            <div className="flex-1">
392              <h4 className="font-medium text-foreground">
393                {bill.description}
394              </h4>
395              <div className="flex items-center gap-2 mt-1">
396                <Calendar className="h-4 w-4 text-muted-foreground" />
397                <span className="text-sm text-muted-foreground">
398                  Due: {new Date(bill.dueDate).toLocaleDateString()}
399                </span>
400              </div>
401            </div>
402            <div className="flex items-center gap-3">
403              <Badge className={getStatusColor(bill.status)}>
404                {bill.status.charAt(0).toUpperCase() + bill.status.slice(1)}
405              </Badge>
406              <div className="text-right">
407                <p className="font-semibold text-foreground">${bill.amount}</p>
408              </div>
409            </div>
410          </div>
411        ))}
412      </div>
413
414      {totalAmount > 0 && (
415        <div className="pt-4">
416          <Button className="w-full" size="lg">
417            <CreditCard className="h-4 w-4 mr-2" />
418            Pay Outstanding Bills (${totalAmount})
419          </Button>
420        </div>
421      )}
422    </div>
423  );
424}
425
426// Main Dashboard Component
427function CustomerAdminDashboard() {
428  const [selectedPlan, setSelectedPlan] = useState("starter");
429  const [selectedAddOns, setSelectedAddOns] = useState<SelectedAddOn[]>([
430    { ...addOns[0], quantity: 5 },
431    { ...addOns[1], quantity: 1 },
432  ]);
433
434  const handleAddOnChange = (addOnId: string, quantity: number) => {
435    setSelectedAddOns((prev) => {
436      const existing = prev.find((addon) => addon.id === addOnId);
437
438      if (quantity === 0) {
439        return prev.filter((addon) => addon.id !== addOnId);
440      }
441
442      if (existing) {
443        return prev.map((addon) =>
444          addon.id === addOnId ? { ...addon, quantity } : addon
445        );
446      }
447
448      const addOn = addOns.find((a) => a.id === addOnId);
449      if (addOn) {
450        return [...prev, { ...addOn, quantity }];
451      }
452
453      return prev;
454    });
455  };
456
457  const selectedPlanData = plans.find((plan) => plan.id === selectedPlan);
458  const totalAddOnsCost = selectedAddOns.reduce(
459    (sum, addon) => sum + addon.price * addon.quantity,
460    0
461  );
462  const totalMonthlyCost = (selectedPlanData?.price || 0) + totalAddOnsCost;
463
464  return (
465    <div className="min-h-screen bg-background p-6">
466      <div className="max-w-7xl mx-auto">
467        <div className="mb-8">
468          <h1 className="text-3xl font-bold text-foreground">
469            Customer Admin Dashboard
470          </h1>
471          <p className="text-muted-foreground">
472            Manage your subscription plans and billing
473          </p>
474        </div>
475
476        <Tabs defaultValue="plans" className="space-y-6">
477          <TabsList className="grid w-full grid-cols-3">
478            <TabsTrigger value="plans">Plans & Add-ons</TabsTrigger>
479            <TabsTrigger value="billing">Current Bills</TabsTrigger>
480            <TabsTrigger value="summary">Summary</TabsTrigger>
481          </TabsList>
482
483          <TabsContent value="plans" className="space-y-6">
484            <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
485              <Card>
486                <CardHeader>
487                  <CardTitle>Select Your Plan</CardTitle>
488                </CardHeader>
489                <CardContent>
490                  <PlanSelector
491                    plans={plans}
492                    selectedPlan={selectedPlan}
493                    onPlanSelect={setSelectedPlan}
494                  />
495                </CardContent>
496              </Card>
497
498              <Card>
499                <CardHeader>
500                  <CardTitle>Add-ons</CardTitle>
501                </CardHeader>
502                <CardContent>
503                  <AddOnsSelector
504                    addOns={addOns}
505                    selectedAddOns={selectedAddOns}
506                    onAddOnChange={handleAddOnChange}
507                  />
508                </CardContent>
509              </Card>
510            </div>
511          </TabsContent>
512
513          <TabsContent value="billing">
514            <Card>
515              <CardHeader>
516                <CardTitle>Billing Overview</CardTitle>
517              </CardHeader>
518              <CardContent>
519                <BillsList bills={sampleBills} />
520              </CardContent>
521            </Card>
522          </TabsContent>
523
524          <TabsContent value="summary">
525            <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
526              <Card>
527                <CardHeader>
528                  <CardTitle>Current Subscription</CardTitle>
529                </CardHeader>
530                <CardContent className="space-y-4">
531                  <div className="flex items-center justify-between">
532                    <span className="text-muted-foreground">Plan:</span>
533                    <span className="font-medium">
534                      {selectedPlanData?.name}
535                    </span>
536                  </div>
537                  <div className="flex items-center justify-between">
538                    <span className="text-muted-foreground">Plan Cost:</span>
539                    <span className="font-medium">
540                      ${selectedPlanData?.price}/month
541                    </span>
542                  </div>
543
544                  <Separator />
545
546                  <div className="space-y-2">
547                    <h4 className="font-medium">Active Add-ons:</h4>
548                    {selectedAddOns.length === 0 ? (
549                      <p className="text-sm text-muted-foreground">
550                        No add-ons selected
551                      </p>
552                    ) : (
553                      selectedAddOns.map((addon) => (
554                        <div
555                          key={addon.id}
556                          className="flex items-center justify-between text-sm"
557                        >
558                          <span>
559                            {addon.name}{" "}
560                            {addon.quantity > 1 && `(${addon.quantity}x)`}
561                          </span>
562                          <span>${addon.price * addon.quantity}/month</span>
563                        </div>
564                      ))
565                    )}
566                  </div>
567
568                  <Separator />
569
570                  <div className="flex items-center justify-between text-lg font-semibold">
571                    <span>Total Monthly Cost:</span>
572                    <span>${totalMonthlyCost}</span>
573                  </div>
574                </CardContent>
575              </Card>
576
577              <Card>
578                <CardHeader>
579                  <CardTitle>Quick Actions</CardTitle>
580                </CardHeader>
581                <CardContent className="space-y-3">
582                  <Button className="w-full" variant="outline">
583                    <DollarSign className="h-4 w-4 mr-2" />
584                    Update Payment Method
585                  </Button>
586                  <Button className="w-full" variant="outline">
587                    <Calendar className="h-4 w-4 mr-2" />
588                    View Billing History
589                  </Button>
590                  <Button className="w-full" variant="outline">
591                    Download Invoice
592                  </Button>
593                  <Button className="w-full">Save Changes</Button>
594                </CardContent>
595              </Card>
596            </div>
597          </TabsContent>
598        </Tabs>
599      </div>
600    </div>
601  );
602}
603
604export default CustomerAdminDashboard;

Dependencies

External Libraries

lucide-reactreact

Shadcn/UI Components

badgebuttoncardseparatortabs

Local Components

/lib/utils

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.