Update Plan Card

PreviousNext

A update plan card component

Docs
billingsdkblock

Preview

Loading preview…
registry/billingsdk/update-plan-card.tsx
"use client";

import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Toggle } from "@/components/ui/toggle";
import { Label } from "@/components/ui/label";
import { type Plan } from "@/lib/billingsdk-config";
import { cn } from "@/lib/utils";

export interface UpdatePlanCardProps {
  currentPlan: Plan;
  plans: Plan[];
  onPlanChange: (planId: string) => void;
  className?: string;
  title?: string;
}

const easing = [0.4, 0, 0.2, 1] as const;

export function UpdatePlanCard({
  currentPlan,
  plans,
  onPlanChange,
  className,
  title,
}: UpdatePlanCardProps) {
  const [isYearly, setIsYearly] = useState(false);
  const [selectedPlan, setSelectedPlan] = useState<string | undefined>(
    undefined,
  );

  const getCurrentPrice = useCallback(
    (plan: Plan) => (isYearly ? `${plan.yearlyPrice}` : `${plan.monthlyPrice}`),
    [isYearly],
  );

  const handlePlanChange = useCallback((planId: string) => {
    setSelectedPlan((prev) => (prev === planId ? undefined : planId));
  }, []);

  return (
    <Card
      className={cn(
        "mx-auto w-full max-w-xl overflow-hidden text-left shadow-lg",
        className,
      )}
    >
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-base font-semibold">
          {title || "Upgrade Plan"}
        </CardTitle>
        <div className="flex items-center gap-2 text-sm">
          <Toggle
            size="sm"
            pressed={!isYearly}
            onPressedChange={(pressed) => setIsYearly(!pressed)}
            className="px-3"
          >
            Monthly
          </Toggle>
          <Toggle
            pressed={isYearly}
            onPressedChange={(pressed) => setIsYearly(pressed)}
            className="px-3"
          >
            Yearly
          </Toggle>
        </div>
      </CardHeader>
      <CardContent className="space-y-3">
        <RadioGroup value={selectedPlan} onValueChange={handlePlanChange}>
          <div className="space-y-2.5 sm:space-y-3">
            {plans.map((plan, index) => (
              <motion.div
                key={plan.id}
                layout
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{
                  layout: { duration: 0.3, ease: easing },
                  opacity: { delay: index * 0.05, duration: 0.3, ease: easing },
                  y: { delay: index * 0.05, duration: 0.3, ease: easing },
                }}
                onClick={() => handlePlanChange(plan.id)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    handlePlanChange(plan.id);
                  }
                }}
                role="button"
                tabIndex={0}
                aria-pressed={selectedPlan === plan.id}
                className={cn(
                  "relative cursor-pointer overflow-hidden rounded-lg border transition-all duration-200 sm:rounded-xl",
                  "focus-visible:ring-primary touch-manipulation focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
                  selectedPlan === plan.id
                    ? "border-primary from-muted/60 to-muted/30 bg-gradient-to-br shadow-sm"
                    : "border-border hover:border-primary/50",
                )}
              >
                <motion.div layout="position" className="p-3 sm:p-4">
                  <div className="flex items-start justify-between gap-2 sm:gap-3">
                    <div className="flex min-w-0 flex-1 gap-2 sm:gap-3">
                      <RadioGroupItem
                        value={plan.id}
                        id={plan.id}
                        className="pointer-events-none mt-0.5 flex-shrink-0 sm:mt-1"
                      />
                      <div className="min-w-0 flex-1">
                        <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
                          <Label
                            htmlFor={plan.id}
                            className="cursor-pointer text-sm leading-tight font-semibold sm:text-base sm:font-medium"
                          >
                            {plan.title}
                          </Label>
                          {plan.badge && (
                            <Badge
                              variant="secondary"
                              className="h-5 flex-shrink-0 px-1.5 py-0 text-[10px] sm:h-auto sm:px-2 sm:py-0.5 sm:text-xs"
                            >
                              {plan.badge}
                            </Badge>
                          )}
                        </div>
                        <p className="text-muted-foreground mt-1 text-[11px] leading-relaxed sm:text-xs">
                          {plan.description}
                        </p>
                        {plan.features.length > 0 && (
                          <div className="pt-2 sm:pt-3">
                            <div className="flex flex-wrap gap-1.5 sm:gap-2">
                              {plan.features.map((feature, featureIndex) => (
                                <div
                                  key={featureIndex}
                                  className="bg-muted/20 border-border/30 flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 sm:gap-2 sm:rounded-lg"
                                >
                                  <div className="bg-primary h-1 w-1 flex-shrink-0 rounded-full sm:h-1.5 sm:w-1.5" />
                                  <span className="text-muted-foreground text-[10px] leading-none whitespace-nowrap sm:text-xs">
                                    {feature.name}
                                  </span>
                                </div>
                              ))}
                            </div>
                          </div>
                        )}
                      </div>
                    </div>
                    <div className="min-w-[60px] flex-shrink-0 text-right sm:min-w-[80px]">
                      <div className="text-base leading-tight font-bold sm:text-xl sm:font-semibold">
                        {parseFloat(getCurrentPrice(plan)) >= 0
                          ? `${plan.currency}${getCurrentPrice(plan)}`
                          : getCurrentPrice(plan)}
                      </div>
                      <div className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
                        /{isYearly ? "year" : "month"}
                      </div>
                    </div>
                  </div>
                </motion.div>

                <AnimatePresence initial={false}>
                  {selectedPlan === plan.id && (
                    <motion.div
                      initial={{ height: 0, opacity: 0 }}
                      animate={{
                        height: "auto",
                        opacity: 1,
                        transition: {
                          height: { duration: 0.3, ease: easing },
                          opacity: {
                            duration: 0.25,
                            delay: 0.05,
                            ease: easing,
                          },
                        },
                      }}
                      exit={{
                        height: 0,
                        opacity: 0,
                        transition: {
                          height: { duration: 0.25, ease: easing },
                          opacity: { duration: 0.15, ease: easing },
                        },
                      }}
                      className="overflow-hidden"
                    >
                      <motion.div
                        initial={{ y: -8 }}
                        animate={{
                          y: 0,
                          transition: {
                            duration: 0.25,
                            delay: 0.05,
                            ease: easing,
                          },
                        }}
                        exit={{ y: -8 }}
                        className="px-3 pb-3 sm:px-4 sm:pb-4"
                      >
                        <Button
                          className="h-10 w-full touch-manipulation text-sm font-medium sm:h-11 sm:text-base"
                          disabled={selectedPlan === currentPlan.id}
                          onClick={(e) => {
                            e.stopPropagation();
                            onPlanChange(plan.id);
                          }}
                        >
                          {selectedPlan === currentPlan.id
                            ? "Current Plan"
                            : "Upgrade"}
                        </Button>
                      </motion.div>
                    </motion.div>
                  )}
                </AnimatePresence>
              </motion.div>
            ))}
          </div>
        </RadioGroup>
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @billingsdk/update-plan-card

Usage

import { UpdatePlanCard } from "@/components/update-plan-card"
<UpdatePlanCard />