Payment Card

PreviousNext

A payment card component

Docs
billingsdkblock

Preview

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

import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { ArrowRight, Check } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";

export interface finalTextProps {
  text: string;
}

export interface PaymentCardProps {
  title: string;
  description: string;
  price: string;
  feature?: string;
  featuredescription?: string;
  feature2?: string;
  feature2description?: string;
  finalText?: finalTextProps[];
  onPay?: (data: {
    cardNumber: string;
    expiry: string;
    cvc: string;
  }) => Promise<void> | void;
  className?: string;
}

export function PaymentCard({
  title,
  description,
  price,
  feature,
  featuredescription,
  feature2,
  feature2description,
  finalText = [],
  onPay,
  className,
}: PaymentCardProps) {
  const [cardNumber, setCardNumber] = useState("");
  const [expiry, setExpiry] = useState("");
  const [cvc, setCvc] = useState("");
  const [index, setIndex] = useState(0);
  const [errors, setErrors] = useState<{
    card?: string;
    expiry?: string;
    cvc?: string;
  }>({});

  const validate = () => {
    const newErrors: typeof errors = {};

    // Card number validation
    if (!/^[0-9 ]{16,19}$/.test(cardNumber)) {
      newErrors.card = "Card number must be 16 digits and only numbers.";
    }

    // Expiry validation
    if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(expiry)) {
      newErrors.expiry = "Enter a valid expiry date (MM/YY).";
    } else {
      const [month, year] = expiry.split("/").map(Number);
      const now = new Date();
      const expDate = new Date(2000 + year, month - 1);
      if (expDate < now) {
        newErrors.expiry = "Expiry date cannot be in the past.";
      }
    }

    // CVC validation
    if (!/^[0-9]{3,4}$/.test(cvc)) {
      newErrors.cvc = "CVC must be 3 or 4 digits.";
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handlePay = () => {
    if (validate()) {
      if (onPay) {
        onPay({ cardNumber, expiry, cvc });
        console.log("Payment processed!");
      } else {
        console.log("error");
      }
    }
  };

  // Final text animation
  useEffect(() => {
    if (!finalText || finalText.length === 0) return;

    const interval = setInterval(() => {
      setIndex((prev) => (prev + 1) % finalText.length);
    }, 2000);

    return () => clearInterval(interval);
  }, [finalText]);

  return (
    <div className={cn("mx-auto w-full max-w-4xl", className)}>
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
        {/* Order Summary */}
        <div className="order-2 md:order-1">
          <div className="space-y-4 md:sticky md:top-6">
            {/* Price Summary */}
            <Card>
              <CardHeader>
                <CardTitle>Order Summary</CardTitle>
              </CardHeader>
              <CardContent>
                <div className="space-y-2">
                  <div className="flex items-center justify-between text-sm">
                    <span className="text-muted-foreground">Subtotal</span>
                    <span className="tabular-nums">${price || "320"}.00</span>
                  </div>
                  <div className="flex items-center justify-between border-t pt-2">
                    <span className="font-medium">Total</span>
                    <span className="text-xl font-semibold tabular-nums">
                      ${price || "320"}.00
                    </span>
                  </div>
                </div>
              </CardContent>
            </Card>

            {/* Features */}
            <Card>
              <CardHeader>
                <CardTitle>What's Included</CardTitle>
              </CardHeader>
              <CardContent>
                <div className="space-y-3">
                  <div className="flex gap-3">
                    <Check className="text-primary mt-0.5 h-4 w-4 shrink-0" />
                    <div className="space-y-1">
                      <p className="text-sm leading-none font-medium">
                        {feature || "Payment & Invoice"}
                      </p>
                      <p className="text-muted-foreground text-sm">
                        {featuredescription ||
                          "Automated billing and detailed transaction records"}
                      </p>
                    </div>
                  </div>

                  <div className="flex gap-3">
                    <Check className="text-primary mt-0.5 h-4 w-4 shrink-0" />
                    <div className="space-y-1">
                      <p className="text-sm leading-none font-medium">
                        {feature2 || "Priority Support"}
                      </p>
                      <p className="text-muted-foreground text-sm">
                        {feature2description ||
                          "Faster response times and technical support"}
                      </p>
                    </div>
                  </div>
                </div>
              </CardContent>
            </Card>
          </div>
        </div>

        {/* Payment Form */}
        <Card className="order-1 md:order-2">
          <CardHeader>
            <CardTitle>{title || "Complete your payment"}</CardTitle>
            <CardDescription>
              {description ||
                "Enter your card details to finalize your subscription"}
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-4">
            {/* Card Number */}
            <div className="space-y-2">
              <Label htmlFor="cardNumber">Card number</Label>
              <div className="relative">
                <div className="absolute top-1/2 left-3 z-10 flex h-6 w-8 -translate-y-1/2 items-center justify-center overflow-hidden">
                  <AnimatePresence mode="wait">
                    <motion.img
                      key={index}
                      src={
                        [
                          "https://img.icons8.com/color/48/visa.png",
                          "https://img.icons8.com/color/48/mastercard-logo.png",
                          "https://img.icons8.com/color/48/amex.png",
                          "https://img.icons8.com/color/48/rupay.png",
                        ][index % 4]
                      }
                      alt="card"
                      className="h-6 w-6"
                      initial={{ opacity: 0, y: 10 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -10 }}
                      transition={{ duration: 0.5 }}
                    />
                  </AnimatePresence>
                </div>
                <Input
                  id="cardNumber"
                  value={cardNumber}
                  onChange={(e) => setCardNumber(e.target.value)}
                  className="pl-14 font-mono tracking-wider"
                  placeholder="1234 5678 9012 3456"
                  maxLength={19}
                />
              </div>
              {errors.card && (
                <p className="text-destructive text-sm">{errors.card}</p>
              )}
            </div>

            {/* Expiry and CVC */}
            <div className="grid grid-cols-2 gap-4">
              <div className="space-y-2">
                <Label htmlFor="expiry">Expiry date</Label>
                <Input
                  id="expiry"
                  value={expiry}
                  onChange={(e) => setExpiry(e.target.value)}
                  className="font-mono"
                  placeholder="MM/YY"
                  maxLength={5}
                />
                {errors.expiry && (
                  <p className="text-destructive text-sm">{errors.expiry}</p>
                )}
              </div>
              <div className="space-y-2">
                <Label htmlFor="cvc">CVC</Label>
                <Input
                  id="cvc"
                  type="password"
                  value={cvc}
                  onChange={(e) => setCvc(e.target.value)}
                  className="font-mono"
                  placeholder="123"
                  maxLength={4}
                />
                {errors.cvc && (
                  <p className="text-destructive text-sm">{errors.cvc}</p>
                )}
              </div>
            </div>

            {/* Discount Code */}
            <div className="space-y-2">
              <Label htmlFor="discount">Discount code (optional)</Label>
              <div className="flex gap-2">
                <Input
                  id="discount"
                  placeholder="Enter code"
                  maxLength={12}
                  className="flex-1"
                />
                <Button variant="secondary" type="button">
                  Apply
                </Button>
              </div>
            </div>

            {/* Pay Button */}
            <Button className="mt-2 w-full" onClick={handlePay}>
              Pay ${price || "320"}.00
              <ArrowRight className="ml-2 h-4 w-4" />
            </Button>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add @billingsdk/payment-card

Usage

import { PaymentCard } from "@/components/payment-card"
<PaymentCard />