shadimage
Billingsdk
blockbilling-settingssource: ok · hit

Billing Settings

A comprehensive billing settings component with tabs for general, payment methods, invoices, and usage limits

npx shadcn add "https://billingsdk.com/r/billing-settings.json"
free

Where it goes

settings-form

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react
toastssonner

Registry dependencies

buttoncarddialogswitchbadgedropdown-menudialoginputlabel

Files2 · source cached 10/9/2026, 12:44:33 PM

src/registry/billingsdk/billing-settings.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, CreditCard, Plus } from "lucide-react";

const tabs = [
  { id: "general", label: "General" },
  { id: "payment", label: "Payment" },
  { id: "invoices", label: "Invoices" },
  { id: "limits", label: "Limits" },
];

interface CardInfo {
  id: string;
  last4: string;
  brand: string;
  expiry: string;
  primary?: boolean;
}

interface BillingSettingsProps {
  activeTab: string;
  onTabChange: (tab: string) => void;

  emailNotifications: boolean;
  onEmailNotificationsChange: (value: boolean) => void;

  usageAlerts: boolean;
  onUsageAlertsChange: (value: boolean) => void;

  invoiceReminders: boolean;
  onInvoiceRemindersChange: (value: boolean) => void;

  cards: CardInfo[];
  onAddCard: () => void;

  invoiceFormat: "PDF" | "HTML";
  onInvoiceFormatChange: (format: "PDF" | "HTML") => void;
  onEditBillingAddress: () => void;

  overageProtection: boolean;
  onOverageProtectionChange: (value: boolean) => void;

  usageLimitAlerts: boolean;
  onUsageLimitAlertsChange: (value: boolean) => void;
  className?: string;
}

interface SettingItemProps {
  title: string;
  description: string;
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;
}

function SettingItem({
  title,
  description,
  checked,
  onCheckedChange,
}: SettingItemProps) {
  const switchId = `switch-${title.toLowerCase().replace(/\s+/g, "-")}`;

  return (
    <div className="flex items-center justify-between gap-4 py-4">
      <div className="min-w-0 flex-1 space-y-0.5">
        <label
          htmlFor={switchId}
          className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
        >
          {title}
        </label>
        <p className="text-muted-foreground text-sm">{description}</p>
      </div>
      <Switch
        id={switchId}
        checked={checked}
        onCheckedChange={onCheckedChange}
        className="shrink-0"
      />
    </div>
  );
}

interface TabNavigationProps {
  activeTab: string;
  onTabChange: (tab: string) => void;
}

function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
  return (
    <div className="bg-muted text-muted-foreground inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg p-1">
      {tabs.map((tab) => (
        <button
          key={tab.id}
          onClick={() => onTabChange(tab.id)}
          className={`ring-offset-background focus-visible:ring-ring inline-flex flex-1 items-center justify-center rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 sm:px-3 sm:text-sm ${
            activeTab === tab.id
              ? "bg-background text-foreground shadow"
              : "hover:bg-background/50 hover:text-foreground"
          }`}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

export function BillingSettings({
  activeTab,
  onTabChange,
  emailNotifications,
  onEmailNotificationsChange,
  usageAlerts,
  onUsageAlertsChange,
  invoiceReminders,
  onInvoiceRemindersChange,
  cards,
  onAddCard,
  invoiceFormat,
  onInvoiceFormatChange,
  onEditBillingAddress,
  overageProtection,
  onOverageProtectionChange,
  usageLimitAlerts,
  onUsageLimitAlertsChange,
  className,
}: BillingSettingsProps) {
  const renderGeneralContent = () => (
    <div className="divide-border space-y-0 divide-y">
      <SettingItem
        title="Email notifications"
        description="Receive billing updates via email"
        checked={emailNotifications}
        onCheckedChange={onEmailNotificationsChange}
      />
      <SettingItem
        title="Usage alerts"
        description="Get notified when approaching limits"
        checked={usageAlerts}
        onCheckedChange={onUsageAlertsChange}
      />
      <SettingItem
        title="Invoice reminders"
        description="Remind me before auto-renewal"
        checked={invoiceReminders}
        onCheckedChange={onInvoiceRemindersChange}
      />
    </div>
  );

  const renderPaymentContent = () => (
    <div className="space-y-4">
      {cards.map((card) => (
        <div
          key={card.id}
          className="flex flex-col justify-between gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:p-4"
        >
          <div className="flex min-w-0 items-center gap-3">
            <CreditCard className="text-muted-foreground h-5 w-5 shrink-0" />
            <div className="min-w-0 flex-1">
              <div className="flex flex-wrap items-center gap-2">
                <span className="font-mono text-sm">•••• {card.last4}</span>
                {card.primary && <Badge variant="secondary">Primary</Badge>}
              </div>
              <p className="text-muted-foreground text-sm">
                {card.brand} • Expires {card.expiry}
              </p>
            </div>
          </div>
        </div>
      ))}
      <Button variant="outline" className="w-full" onClick={onAddCard}>
        <Plus className="mr-2 h-4 w-4" />
        Add new card
      </Button>
    </div>
  );

  const renderInvoicesContent = () => (
    <div className="space-y-6">
      <div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
        <div className="flex-1 space-y-0.5">
          <h3 className="text-sm font-medium">Invoice format</h3>
          <p className="text-muted-foreground text-sm">
            Choose PDF or HTML format
          </p>
        </div>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" size="sm" className="w-full sm:w-auto">
              {invoiceFormat}
              <ChevronDown className="ml-2 h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={() => onInvoiceFormatChange("PDF")}>
              PDF
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => onInvoiceFormatChange("HTML")}>
              HTML
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
      <div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
        <div className="flex-1 space-y-0.5">
          <h3 className="text-sm font-medium">Billing address</h3>
          <p className="text-muted-foreground text-sm">
            Update your billing address
          </p>
        </div>
        <Button
          variant="outline"
          size="sm"
          className="w-full sm:w-auto"
          onClick={onEditBillingAddress}
        >
          Edit
        </Button>
      </div>
    </div>
  );

  const renderLimitsContent = () => (
    <div className="divide-border space-y-0 divide-y">
      <SettingItem
        title="Overage protection"
        description="Prevent accidental overages"
        checked={overageProtection}
        onCheckedChange={onOverageProtectionChange}
      />
      <SettingItem
        title="Usage limit alerts"
        description="Alert at 80% and 95% usage"
        checked={usageLimitAlerts}
        onCheckedChange={onUsageLimitAlertsChange}
      />
    </div>
  );

  const renderTabContent = () => {
    switch (activeTab) {
      case "general":
        return renderGeneralContent();
      case "payment":
        return renderPaymentContent();
      case "invoices":
        return renderInvoicesContent();
      case "limits":
        return renderLimitsContent();
      default:
        return renderGeneralContent();
    }
  };

  return (
    <div className={`w-full px-4 sm:px-0 ${className || ""}`}>
      <Card className="mx-auto w-full max-w-2xl">
        <CardHeader className="px-4 sm:px-6">
          <CardTitle>Billing Settings</CardTitle>
        </CardHeader>
        <CardContent className="space-y-6 px-4 sm:px-6">
          <TabNavigation activeTab={activeTab} onTabChange={onTabChange} />
          {renderTabContent()}
        </CardContent>
      </Card>
    </div>
  );
}
src/registry/billingsdk/demo/billing-settings-demo.tsx
"use client";

import { useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Toaster, toast } from "sonner";
import { BillingSettings } from "@/components/billingsdk/billing-settings";
import { CreditCard } from "lucide-react";

type InvoiceFormat = "PDF" | "HTML";

interface Card {
  id: string;
  last4: string;
  brand: "Visa" | "MasterCard" | "Amex" | "Other";
  expiry: string;
  primary: boolean;
}

interface NewCardForm {
  number: string;
  expiry: string;
  cvc: string;
}

export default function BillingSettingsDemo() {
  const [activeTab, setActiveTab] = useState<
    "general" | "payment" | "invoices" | "limits"
  >("general");
  const [emailNotifications, setEmailNotifications] = useState<boolean>(true);
  const [usageAlerts, setUsageAlerts] = useState<boolean>(true);
  const [invoiceReminders, setInvoiceReminders] = useState<boolean>(false);
  const [cards, setCards] = useState<Card[]>([
    { id: "1", last4: "4242", brand: "Visa", expiry: "12/25", primary: true },
  ]);
  const [invoiceFormat, setInvoiceFormat] = useState<InvoiceFormat>("PDF");
  const [overageProtection, setOverageProtection] = useState<boolean>(true);
  const [usageLimitAlerts, setUsageLimitAlerts] = useState<boolean>(true);
  const [newCard, setNewCard] = useState<NewCardForm>({
    number: "",
    expiry: "",
    cvc: "",
  });
  const [open, setOpen] = useState<boolean>(false);

  const handleToggleEmailNotifications = (checked: boolean) => {
    console.log(`Email notifications toggled to: ${checked}`);
    setEmailNotifications(checked);
  };

  const handleToggleUsageAlerts = (checked: boolean) => {
    console.log(`Usage alerts toggled to: ${checked}`);
    setUsageAlerts(checked);
  };

  const handleToggleInvoiceReminders = (checked: boolean) => {
    console.log(`Invoice reminders toggled to: ${checked}`);
    setInvoiceReminders(checked);
  };

  const handleChangeInvoiceFormat = (format: InvoiceFormat) => {
    console.log(`Invoice format changed to: ${format}`);
    setInvoiceFormat(format);
  };

  const handleEditBillingAddress = () => {
    console.log("Edit billing address button clicked");
    toast.info("Edit billing address clicked!");
  };

  const handleToggleOverageProtection = (checked: boolean) => {
    console.log(`Overage protection toggled to: ${checked}`);
    setOverageProtection(checked);
  };

  const handleToggleUsageLimitAlerts = (checked: boolean) => {
    console.log(`Usage limit alerts toggled to: ${checked}`);
    setUsageLimitAlerts(checked);
  };

  const isValidCardNumber = (number: string): boolean => {
    const normalized = number.replace(/\s/g, "");
    return (
      normalized.length >= 13 &&
      normalized.length <= 19 &&
      /^\d+$/.test(normalized)
    );
  };

  const isValidExpiry = (expiry: string): boolean => {
    if (!/^(0[1-9]|1[0-2])\/?([0-9]{2})$/.test(expiry)) {
      return false;
    }
    const [month, year] = expiry.split("/").map(Number);
    const currentYear = Number(String(new Date().getFullYear()).slice(-2));
    const currentMonth = new Date().getMonth() + 1;
    if (year < currentYear || (year === currentYear && month < currentMonth)) {
      return false;
    }
    return true;
  };

  const isValidCvc = (cvc: string): boolean => {
    return /^\d{3,4}$/.test(cvc);
  };

  const detectCardBrand = (number: string): Card["brand"] => {
    if (number.startsWith("4")) return "Visa";
    if (/^5[1-5]/.test(number)) return "MasterCard";
    if (/^3[47]/.test(number)) return "Amex";
    return "Other";
  };

  const handleAddCard = (): void => {
    if (!isValidCardNumber(newCard.number)) {
      toast.error("Please enter a valid card number.");
      return;
    }
    if (!isValidExpiry(newCard.expiry)) {
      toast.error(
        "Please enter a valid expiry date (MM/YY) that's not in the past.",
      );
      return;
    }
    if (!isValidCvc(newCard.cvc)) {
      toast.error("Please enter a valid CVC.");
      return;
    }
    const last4 = newCard.number.slice(-4);
    const brand = detectCardBrand(newCard.number);
    const newCardData: Card = {
      id: String(cards.length + 1),
      last4,
      brand,
      expiry: newCard.expiry,
      primary: cards.length === 0,
    };
    setCards([...cards, newCardData]);
    setNewCard({ number: "", expiry: "", cvc: "" });
    setOpen(false);
    toast.success("Card added successfully!");
  };

  const formatCardNumber = (value: string): string => {
    const rawValue = value.replace(/\D/g, "");
    const formattedValue = rawValue.match(/.{1,4}/g)?.join(" ") || "";
    return formattedValue;
  };

  return (
    <div className="p-6">
      <BillingSettings
        activeTab={activeTab}
        onTabChange={(tab: string) =>
          setActiveTab(tab as "general" | "payment" | "invoices" | "limits")
        }
        emailNotifications={emailNotifications}
        onEmailNotificationsChange={handleToggleEmailNotifications}
        usageAlerts={usageAlerts}
        onUsageAlertsChange={handleToggleUsageAlerts}
        invoiceReminders={invoiceReminders}
        onInvoiceRemindersChange={handleToggleInvoiceReminders}
        cards={cards}
        onAddCard={() => setOpen(true)}
        invoiceFormat={invoiceFormat}
        onInvoiceFormatChange={handleChangeInvoiceFormat}
        onEditBillingAddress={handleEditBillingAddress}
        overageProtection={overageProtection}
        onOverageProtectionChange={handleToggleOverageProtection}
        usageLimitAlerts={usageLimitAlerts}
        onUsageLimitAlertsChange={handleToggleUsageLimitAlerts}
      />
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>Add Payment Card</DialogTitle>
            <DialogDescription>
              Enter your card details to add a new payment method
            </DialogDescription>
          </DialogHeader>
          <div className="space-y-4 py-4">
            <div className="space-y-2">
              <Label htmlFor="number">Card Number</Label>
              <div className="relative">
                <Input
                  id="number"
                  value={formatCardNumber(newCard.number)}
                  onChange={(e) => {
                    const rawValue = e.target.value.replace(/\s/g, "");
                    if (rawValue.length <= 19) {
                      setNewCard({ ...newCard, number: rawValue });
                    }
                  }}
                  placeholder="1234 5678 9012 3456"
                  className="pr-10"
                  maxLength={19}
                />
                <CreditCard className="text-muted-foreground absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2" />
              </div>
            </div>
            <div className="grid grid-cols-2 gap-4">
              <div className="space-y-2">
                <Label htmlFor="expiry">Expiry Date</Label>
                <Input
                  id="expiry"
                  value={newCard.expiry}
                  onChange={(e) => {
                    let value = e.target.value.replace(/\D/g, "");
                    if (value.length >= 2) {
                      value = value.slice(0, 2) + "/" + value.slice(2, 4);
                    }
                    if (value.length <= 5) {
                      setNewCard({ ...newCard, expiry: value });
                    }
                  }}
                  placeholder="MM/YY"
                  maxLength={5}
                />
              </div>
              <div className="space-y-2">
                <Label htmlFor="cvc">CVC</Label>
                <Input
                  id="cvc"
                  type="password"
                  value={newCard.cvc}
                  onChange={(e) => {
                    const value = e.target.value.replace(/\D/g, "");
                    if (value.length <= 4) {
                      setNewCard({ ...newCard, cvc: value });
                    }
                  }}
                  placeholder="123"
                  maxLength={4}
                />
              </div>
            </div>
          </div>
          <DialogFooter className="gap-2 sm:gap-0">
            <Button variant="outline" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button onClick={handleAddCard}>Add Card</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
      <Toaster />
    </div>
  );
}

More from Billingsdk

Browse all components in this library →