Pricing Table Five

PreviousNext

A modern pricing table component with contact us plan

Docs
billingsdkblock

Preview

Loading preview…
registry/billingsdk/pricing-table-five.tsx
"use client";

import { useState, useId } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Check, Phone } from "lucide-react";
import { type Plan } from "@/lib/billingsdk-config";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { AnimatePresence, motion } from "motion/react";

const sectionVariants = cva("py-32 relative overflow-hidden", {
  variants: {
    size: {
      small: "py-12",
      medium: "py-20",
      large: "py-32",
    },
    theme: {
      minimal: "bg-background",
      classic: "bg-gradient-to-b from-background to-muted/20",
    },
  },
  defaultVariants: {
    size: "medium",
    theme: "minimal",
  },
});

const titleVariants = cva("font-bold mb-4 text-foreground", {
  variants: {
    size: {
      small: "text-3xl lg:text-4xl",
      medium: "text-4xl lg:text-5xl",
      large: "text-4xl lg:text-6xl",
    },
    theme: {
      minimal: "",
      classic:
        "bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent",
    },
  },
  defaultVariants: {
    size: "medium",
    theme: "minimal",
  },
});

const descriptionVariants = cva(
  "text-muted-foreground max-w-3xl mx-auto mb-8",
  {
    variants: {
      size: {
        small: "text-base lg:text-lg",
        medium: "text-lg lg:text-xl",
        large: "lg:text-xl",
      },
    },
    defaultVariants: {
      size: "medium",
    },
  },
);

const toggleVariants = cva(
  "flex h-11 w-fit shrink-0 items-center rounded-md p-1 text-lg",
  {
    variants: {
      theme: {
        minimal: "bg-muted",
        classic:
          "bg-muted/50 backdrop-blur-sm border border-border/50 shadow-lg",
      },
    },
    defaultVariants: {
      theme: "minimal",
    },
  },
);

const planCardVariants = cva(
  "relative border transition-all duration-300 rounded-lg",
  {
    variants: {
      size: {
        small: "p-4",
        medium: "p-5",
        large: "p-6",
      },
      theme: {
        minimal: "bg-card border-border hover:bg-muted/30 shadow-sm",
        classic:
          "bg-card border-border/50 hover:shadow-xl hover:border-border backdrop-blur-sm shadow-md",
      },
      highlight: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      {
        theme: "classic",
        highlight: true,
        className:
          "ring-1 ring-primary/20 border-primary/30 bg-gradient-to-b from-primary/5 to-card shadow-lg",
      },
      {
        theme: "minimal",
        highlight: true,
        className: "bg-muted/50 border-primary/20 shadow-md",
      },
    ],
    defaultVariants: {
      size: "large",
      theme: "minimal",
      highlight: false,
    },
  },
);

const contactCardVariants = cva(
  "border transition-all duration-300 h-full rounded-lg",
  {
    variants: {
      size: {
        small: "p-6",
        medium: "p-7",
        large: "p-8",
      },
      theme: {
        minimal: "bg-muted/50 border-border hover:bg-muted/70 shadow-sm",
        classic:
          "bg-card border-border/50 hover:shadow-xl hover:border-primary/20 backdrop-blur-sm shadow-md",
      },
    },
    defaultVariants: {
      size: "large",
      theme: "minimal",
    },
  },
);

const priceTextVariants = cva("font-bold", {
  variants: {
    size: {
      small: "text-3xl",
      medium: "text-4xl",
      large: "text-4xl",
    },
    theme: {
      minimal: "text-foreground",
      classic:
        "font-extrabold bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

const featureIconVariants = cva("flex-none", {
  variants: {
    size: {
      small: "w-3 h-3",
      medium: "w-4 h-4",
      large: "w-4 h-4",
    },
    theme: {
      minimal: "text-primary",
      classic: "text-emerald-500",
    },
  },
  defaultVariants: {
    size: "large",
    theme: "minimal",
  },
});

export interface PricingTableFiveProps extends VariantProps<
  typeof sectionVariants
> {
  plans: Plan[];
  title?: string;
  description?: string;
  onPlanSelect?: (planId: string) => void;
  className?: string;
}

export function PricingTableFive({
  plans,
  title = "Pricing Plans",
  description = "Choose the plan that's right for you",
  onPlanSelect,
  className,
  size = "medium",
  theme = "minimal",
}: PricingTableFiveProps) {
  const [isAnnually, setIsAnnually] = useState(false);
  const uniqueId = useId();

  function calculateDiscount(
    monthlyPrice: string,
    yearlyPrice: string,
  ): number {
    const monthly = parseFloat(monthlyPrice);
    const yearly = parseFloat(yearlyPrice);

    if (
      monthlyPrice.toLowerCase() === "custom" ||
      yearlyPrice.toLowerCase() === "custom" ||
      isNaN(monthly) ||
      isNaN(yearly) ||
      monthly === 0
    ) {
      return 0;
    }

    const discount = ((monthly * 12 - yearly) / (monthly * 12)) * 100;
    return Math.round(discount);
  }

  const yearlyPriceDiscount = plans.length
    ? Math.max(
        ...plans.map((plan) =>
          calculateDiscount(plan.monthlyPrice, plan.yearlyPrice),
        ),
      )
    : 0;

  const regularPlans = plans.slice(0, -1);
  const contactUsPlan = plans[plans.length - 1];

  return (
    <section className={cn(sectionVariants({ size, theme }), className)}>
      {/* Classic theme background elements */}
      {theme === "classic" && (
        <>
          <div className="bg-grid-pattern absolute inset-0 opacity-5" />
          <div className="bg-primary/5 absolute top-1/2 left-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
          <div className="bg-secondary/5 absolute top-1/4 right-1/4 h-64 w-64 rounded-full blur-2xl" />
        </>
      )}

      <div className="relative container mx-auto max-w-7xl">
        {/* Header */}
        <div className="mb-12 pb-1 text-center">
          <h2 className={cn(titleVariants({ size, theme }), "leading-[1.12]")}>
            {title}
          </h2>
          <p className={cn(descriptionVariants({ size }))}>{description}</p>

          {/* Billing Toggle */}
          <div
            className={cn(
              "mx-auto mt-8 flex justify-center",
              toggleVariants({ theme }),
            )}
          >
            <RadioGroup
              defaultValue="monthly"
              className="h-full grid-cols-2"
              onValueChange={(value) => {
                setIsAnnually(value === "annually");
              }}
            >
              <div className='has-[button[data-state="checked"]]:bg-background h-full rounded-md transition-all'>
                <RadioGroupItem
                  value="monthly"
                  id={`${uniqueId}-monthly`}
                  className="peer sr-only"
                />
                <Label
                  htmlFor={`${uniqueId}-monthly`}
                  className="text-muted-foreground peer-data-[state=checked]:text-primary hover:text-foreground flex h-full cursor-pointer items-center justify-center px-2 font-semibold transition-all md:px-7"
                >
                  Monthly
                </Label>
              </div>
              <div className='has-[button[data-state="checked"]]:bg-background h-full rounded-md transition-all'>
                <RadioGroupItem
                  value="annually"
                  id={`${uniqueId}-annually`}
                  className="peer sr-only"
                />
                <Label
                  htmlFor={`${uniqueId}-annually`}
                  className="text-muted-foreground peer-data-[state=checked]:text-primary hover:text-foreground flex h-full cursor-pointer items-center justify-center gap-1 px-2 font-semibold transition-all md:px-7"
                >
                  Annually
                  {yearlyPriceDiscount > 0 && (
                    <span className="bg-primary/10 text-primary border-primary/20 ml-1 rounded border px-2 py-0.5 text-xs font-medium">
                      Save {yearlyPriceDiscount}%
                    </span>
                  )}
                </Label>
              </div>
            </RadioGroup>
          </div>
        </div>

        {/* Plans Layout */}
        <div className="flex flex-col gap-6 lg:flex-row">
          {/* Regular Plans */}
          <div className="flex flex-col gap-4 lg:w-2/3">
            {regularPlans.map((plan, index) => (
              <motion.div
                key={plan.id}
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.4, delay: index * 0.1 }}
              >
                <Card
                  className={cn(
                    planCardVariants({
                      size,
                      theme,
                      highlight: plan.highlight,
                    }),
                  )}
                >
                  {plan.badge && (
                    <Badge
                      className={cn(
                        "absolute -top-3 left-1/2 z-10 -translate-x-1/2 transform",
                        theme === "classic"
                          ? "from-primary to-primary/80 text-primary-foreground border-primary/20 bg-gradient-to-r shadow-lg"
                          : "bg-primary text-primary-foreground",
                      )}
                    >
                      {plan.badge}
                    </Badge>
                  )}

                  {theme === "classic" && plan.highlight && (
                    <div className="via-primary absolute -top-px left-1/2 h-px w-32 -translate-x-1/2 bg-gradient-to-r from-transparent to-transparent" />
                  )}

                  <CardContent className="flex flex-col gap-6 p-0 md:flex-row md:items-center md:justify-between">
                    {/* Plan Info and Price */}
                    <div className="flex min-w-[200px] flex-col gap-3">
                      <Badge
                        variant="outline"
                        className="w-fit text-xs font-medium uppercase"
                      >
                        {plan.title}
                      </Badge>

                      <AnimatePresence mode="wait">
                        <motion.div
                          key={isAnnually ? "year" : "month"}
                          initial={{ opacity: 0, y: 10 }}
                          animate={{ opacity: 1, y: 0 }}
                          exit={{ opacity: 0, y: -10 }}
                          transition={{ duration: 0.2 }}
                          className="flex items-baseline gap-1"
                        >
                          <span
                            className={cn(priceTextVariants({ size, theme }))}
                          >
                            {parseFloat(
                              isAnnually ? plan.yearlyPrice : plan.monthlyPrice,
                            ) >= 0 && <>{plan.currency}</>}
                            {isAnnually ? plan.yearlyPrice : plan.monthlyPrice}
                          </span>
                          <span className="text-muted-foreground text-sm">
                            /{isAnnually ? "year" : "month"}
                          </span>
                        </motion.div>
                      </AnimatePresence>

                      <Button
                        onClick={() => onPlanSelect?.(plan.id)}
                        className={cn(
                          "w-full md:w-auto",
                          plan.highlight &&
                            theme === "minimal" &&
                            "hover:bg-primary/90 group bg-primary text-primary-foreground ring-primary before:from-primary-foreground/20 after:from-primary-foreground/10 relative isolate inline-flex h-9 items-center justify-center overflow-hidden rounded-md px-6 py-2 text-left text-sm font-medium shadow ring-1 before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-gradient-to-b before:opacity-80 before:transition-opacity before:duration-300 before:ease-[cubic-bezier(0.4,0.36,0,1)] after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:rounded-md after:bg-gradient-to-b after:to-transparent after:mix-blend-overlay hover:cursor-pointer",
                        )}
                        variant={plan.highlight ? "default" : "secondary"}
                      >
                        {plan.buttonText}
                      </Button>
                    </div>

                    {/* Features */}
                    <div className="grid flex-1 gap-3 md:grid-cols-2">
                      {plan.features.map((feature, featureIndex) => (
                        <motion.div
                          key={featureIndex}
                          className="flex items-start gap-2"
                          initial={{ opacity: 0, x: -10 }}
                          animate={{ opacity: 1, x: 0 }}
                          transition={{
                            duration: 0.3,
                            delay: featureIndex * 0.05,
                          }}
                        >
                          <Check
                            className={cn(
                              featureIconVariants({ size, theme }),
                              "mt-0.5",
                            )}
                          />
                          <span
                            className={cn(
                              "text-sm",
                              theme === "classic"
                                ? "text-foreground/90"
                                : "text-muted-foreground",
                            )}
                          >
                            {feature.name}
                          </span>
                        </motion.div>
                      ))}
                    </div>
                  </CardContent>
                </Card>
              </motion.div>
            ))}
          </div>

          {/* Contact Card */}
          <motion.div
            className="lg:w-1/3"
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.4, delay: regularPlans.length * 0.1 }}
          >
            <Card
              className={cn(contactCardVariants({ size, theme }), "rounded-lg")}
            >
              <CardContent className="flex flex-col items-center space-y-6 p-0 text-center">
                <div className="bg-primary/10 flex h-16 w-16 items-center justify-center rounded-full">
                  <Phone className="text-primary h-8 w-8" />
                </div>

                <div>
                  <h3
                    className={cn(
                      "mb-2 text-2xl font-bold",
                      theme === "classic" &&
                        "from-foreground to-muted-foreground bg-gradient-to-r bg-clip-text text-transparent",
                    )}
                  >
                    {contactUsPlan.title}
                  </h3>
                  <p className="text-muted-foreground text-sm leading-relaxed">
                    {contactUsPlan.description}
                  </p>
                </div>

                <Button
                  onClick={() => onPlanSelect?.(contactUsPlan.id)}
                  variant="outline"
                  className="hover:bg-primary hover:text-primary-foreground w-full transition-colors"
                >
                  {contactUsPlan.buttonText}
                </Button>

                <p className="text-muted-foreground text-xs">
                  Custom pricing and solutions available
                </p>
              </CardContent>
            </Card>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Installation

npx shadcn@latest add @billingsdk/pricing-table-five

Usage

import { PricingTableFive } from "@/components/pricing-table-five"
<PricingTableFive />