Payment Details Two

PreviousNext

A comprehensive payment details form component with card details, billing information, and country selection built using React Hook Form and custom validation

Docs
billingsdkblock

Preview

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

import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  detectCardType,
  formatCardNumber,
  formatExpiryDate,
  validateLuhn,
} from "@/utils/card-validation";
import {
  Check,
  CreditCard,
  Shield,
  ChevronLeft,
  ChevronRight,
} from "lucide-react";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { motion, AnimatePresence } from "motion/react";
import { Country, State, City } from "country-state-city";

export interface PaymentFormData {
  nameOnCard?: string;
  cardNumber?: string;
  validTill?: string;
  cvv?: string;
  firstName?: string;
  middleLastName?: string;
  country?: string;
  state?: string;
  city?: string;
  billingAddress?: string;
  pinCode?: string;
  contactNumber?: string;
  general?: string;
}

const CardLogo = ({ type }: { type: string }) => {
  switch (type) {
    case "visa":
      return (
        <div className="flex h-5 w-10 items-center justify-center rounded bg-blue-600 text-xs font-bold text-white">
          VISA
        </div>
      );
    case "mastercard":
      return (
        <div className="flex items-center">
          <div className="h-5 w-5 rounded-full bg-red-500"></div>
          <div className="-ml-2 h-5 w-5 rounded-full bg-orange-400"></div>
        </div>
      );
    case "amex":
      return (
        <div className="flex h-6 w-10 items-center justify-center rounded bg-blue-500 text-xs font-bold text-white">
          AMEX
        </div>
      );
    case "rupay":
      return (
        <div className="flex h-5 w-10 items-center justify-center rounded bg-green-600 text-xs font-bold text-white">
          RuPay
        </div>
      );
    case "discover":
      return (
        <div className="flex h-6 w-10 items-center justify-center rounded bg-orange-600 text-xs font-bold text-white">
          DISC
        </div>
      );
    default:
      return <CreditCard className="text-muted-foreground h-5 w-5" />;
  }
};

export function PaymentDetailsTwo({
  className,
  onSubmit,
  onDiscard,
  countries,
  states,
  cities,
}: {
  className?: string;
  onSubmit?: (data: PaymentFormData) => void;
  onDiscard?: () => void;
  countries?: { name: string; isoCode: string }[];
  states?: { name: string; isoCode: string }[];
  cities?: { name: string }[];
}) {
  const [step, setStep] = useState(1);
  const [cardType, setCardType] = useState("");
  const [isSaved, setIsSaved] = useState(false);
  const [defaultCountries, setDefaultCountries] = useState<
    { name: string; isoCode: string }[]
  >([]);
  const [defaultStates, setDefaultStates] = useState<
    { name: string; isoCode: string }[]
  >([]);
  const [defaultCities, setDefaultCities] = useState<{ name: string }[]>([]);
  const [selectedCountry, setSelectedCountry] = useState<string>("");
  const [selectedState, setSelectedState] = useState<string>("");

  const {
    register,
    handleSubmit,
    setValue,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<PaymentFormData>({
    defaultValues: {
      nameOnCard: "",
      cardNumber: "",
      validTill: "",
      cvv: "",
      firstName: "",
      middleLastName: "",
      country: "",
      state: "",
      city: "",
      billingAddress: "",
      pinCode: "",
      contactNumber: "",
    },
  });

  useEffect(() => {
    // fetch all countries
    if (countries && countries?.length > 0) {
      setDefaultCountries(countries);
    } else {
      const countryData = Country.getAllCountries();
      setDefaultCountries(countryData);
    }
  }, []);

  useEffect(() => {
    if (states && states.length > 0) {
      setDefaultStates(states);
    } else {
      const stateData = State.getStatesOfCountry(selectedCountry);
      setDefaultStates(stateData);
    }
  }, [selectedCountry]);

  useEffect(() => {
    if (cities && cities.length > 0) {
      setDefaultCities(cities);
    } else {
      const cityData = City.getCitiesOfState(selectedCountry, selectedState);
      setDefaultCities(cityData);
    }
  }, [selectedCountry, selectedState]);

  const handleFormSubmit = async (data: PaymentFormData) => {
    if (!onSubmit) return;
    try {
      await onSubmit(data);
      setIsSaved(true);
    } catch (err) {
      console.error(err);
    }
  };

  const handleDiscardClick = () => {
    if (onDiscard) {
      onDiscard();
    } else {
      reset();
      setIsSaved(false);
    }
  };

  const formatAndSetCard = (val: string) => {
    const raw = val.replace(/\s+/g, "");
    const formatted = formatCardNumber(raw);
    setValue("cardNumber", formatted);
    setCardType(detectCardType(formatted));
  };

  const handleNext = () => {
    setStep(2);
  };

  const handleBack = () => {
    setStep(1);
  };

  return (
    <Card className={cn("mx-auto w-full max-w-2xl pb-0", className)}>
      <CardHeader className="space-y-4">
        <div>
          <CardTitle>Payment Details</CardTitle>
          <CardDescription className="mt-1.5">
            {step === 1
              ? "Enter your card information"
              : "Enter your billing address"}
          </CardDescription>
        </div>

        {/* Progress Indicator */}
        <div className="flex items-center gap-2">
          <div className="flex flex-1 items-center gap-2">
            <div
              className={cn(
                "flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors",
                step === 1
                  ? "bg-primary text-primary-foreground"
                  : "bg-primary/20 text-primary",
              )}
            >
              1
            </div>
            <div className="bg-border h-1 flex-1 rounded-full">
              <div
                className={cn(
                  "bg-primary h-full rounded-full transition-all duration-300",
                  step === 2 ? "w-full" : "w-0",
                )}
              />
            </div>
          </div>
          <div
            className={cn(
              "flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors",
              step === 2
                ? "bg-primary text-primary-foreground"
                : "bg-muted text-muted-foreground",
            )}
          >
            2
          </div>
        </div>
      </CardHeader>

      <form onSubmit={handleSubmit(handleFormSubmit)}>
        <CardContent className="min-h-[420px] py-6">
          <AnimatePresence mode="wait">
            {step === 1 ? (
              <motion.div
                key="step1"
                initial={{ opacity: 0, x: 20 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, x: -20 }}
                transition={{ duration: 0.3 }}
                className="space-y-5"
              >
                <div className="space-y-2">
                  <Label htmlFor="nameOnCard">Name on card</Label>
                  <Input
                    id="nameOnCard"
                    placeholder="John Doe"
                    {...register("nameOnCard", {
                      required: "Name is required",
                    })}
                  />
                  {errors.nameOnCard && (
                    <p className="text-destructive text-sm">
                      {errors.nameOnCard.message}
                    </p>
                  )}
                </div>

                <div className="space-y-2">
                  <Label htmlFor="cardNumber">Card number</Label>
                  <div className="relative">
                    <div className="absolute top-1/2 left-3 z-10 -translate-y-1/2">
                      <CardLogo type={cardType} />
                    </div>
                    <Input
                      id="cardNumber"
                      className="pl-14 font-mono tracking-wide"
                      placeholder="1234 5678 9012 3456"
                      maxLength={20}
                      inputMode="numeric"
                      pattern="[0-9 ]*"
                      {...register("cardNumber", {
                        required: "Card number is required",
                        validate: (val) =>
                          val ? validateLuhn(val) : "Invalid card number",
                      })}
                      onChange={(e) => formatAndSetCard(e.target.value)}
                    />
                  </div>
                  {errors.cardNumber && (
                    <p className="text-destructive text-sm">
                      {errors.cardNumber.message}
                    </p>
                  )}
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <div className="space-y-2">
                    <Label htmlFor="validTill">Expiry date</Label>
                    <Input
                      id="validTill"
                      className="font-mono"
                      placeholder="MM/YY"
                      maxLength={5}
                      {...register("validTill", {
                        required: "Valid expiry date is required (MM/YY)",
                        validate: (val?: string) => {
                          if (!val)
                            return "Valid expiry date is required (MM/YY)";
                          if (!/^\d{2}\/\d{2}$/.test(val))
                            return "Invalid format (MM/YY)";
                          const [mm, yy] = val.split("/").map(Number);
                          if (mm < 1 || mm > 12) return "Invalid month";
                          const now = new Date();
                          const expiry = new Date(2000 + yy, mm - 1, 1);
                          if (expiry < now) return "Card expired";
                          return true;
                        },
                      })}
                      onChange={(e) =>
                        setValue("validTill", formatExpiryDate(e.target.value))
                      }
                    />
                    {errors.validTill && (
                      <p className="text-destructive text-sm">
                        {errors.validTill.message}
                      </p>
                    )}
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="cvv">CVV</Label>
                    <div className="relative">
                      <Shield className="text-muted-foreground absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
                      <Input
                        id="cvv"
                        type="password"
                        className="pl-10 font-mono"
                        placeholder="123"
                        maxLength={4}
                        inputMode="numeric"
                        pattern="[0-9 ]*"
                        {...register("cvv", {
                          required: "Valid CVV is required",
                          validate: (val) =>
                            (cardType === "amex"
                              ? val?.length === 4
                              : val?.length === 3) || "Invalid CVV length",
                        })}
                      />
                    </div>
                    {errors.cvv && (
                      <p className="text-destructive text-sm">
                        {errors.cvv.message}
                      </p>
                    )}
                  </div>
                </div>
              </motion.div>
            ) : (
              <motion.div
                key="step2"
                initial={{ opacity: 0, x: 20 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, x: -20 }}
                transition={{ duration: 0.3 }}
                className="space-y-5"
              >
                <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
                  <div className="space-y-2">
                    <Label htmlFor="firstName">First name</Label>
                    <Input
                      id="firstName"
                      placeholder="John"
                      {...register("firstName", { required: "Required" })}
                    />
                    {errors.firstName && (
                      <p className="text-destructive text-sm">
                        {errors.firstName.message}
                      </p>
                    )}
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="middleLastName">Last name</Label>
                    <Input
                      id="middleLastName"
                      placeholder="Doe"
                      {...register("middleLastName", { required: "Required" })}
                    />
                    {errors.middleLastName && (
                      <p className="text-destructive text-sm">
                        {errors.middleLastName.message}
                      </p>
                    )}
                  </div>
                </div>

                <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
                  <div className="space-y-2">
                    <Label htmlFor="country">Country</Label>
                    <Select
                      onValueChange={(val) => {
                        setValue("country", val);
                        setSelectedCountry(val);
                      }}
                    >
                      <SelectTrigger id="country">
                        <SelectValue placeholder="Select country" />
                      </SelectTrigger>
                      <SelectContent>
                        {defaultCountries.map((c) => (
                          <SelectItem key={c.isoCode} value={c.isoCode}>
                            {c.name}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>

                  <div className="space-y-2">
                    <Label htmlFor="state">State</Label>
                    <Select
                      onValueChange={(val) => {
                        setValue("state", val);
                        setSelectedState(val);
                      }}
                    >
                      <SelectTrigger id="state">
                        <SelectValue placeholder="Select state" />
                      </SelectTrigger>
                      <SelectContent>
                        {defaultStates.length > 0 ? (
                          defaultStates.map((s) => (
                            <SelectItem key={s.isoCode} value={s.isoCode}>
                              {s.name}{" "}
                            </SelectItem>
                          ))
                        ) : (
                          <SelectItem disabled value="No state found">
                            No state found
                          </SelectItem>
                        )}
                      </SelectContent>
                    </Select>
                  </div>

                  <div className="space-y-2">
                    <Label htmlFor="city">City</Label>
                    <Select onValueChange={(val) => setValue("city", val)}>
                      <SelectTrigger id="city">
                        <SelectValue placeholder="Select city" />
                      </SelectTrigger>
                      <SelectContent>
                        {defaultCities.length > 0 ? (
                          defaultCities.map((c) => (
                            <SelectItem key={c.name} value={c.name}>
                              {c.name}
                            </SelectItem>
                          ))
                        ) : (
                          <SelectItem disabled value="No city found">
                            No city found
                          </SelectItem>
                        )}
                      </SelectContent>
                    </Select>
                  </div>
                </div>

                <div className="space-y-2">
                  <Label htmlFor="billingAddress">Billing Address</Label>
                  <Textarea
                    id="billingAddress"
                    placeholder="Enter Billing Address"
                    {...register("billingAddress", { required: "Required" })}
                  />
                  {errors.billingAddress && (
                    <p className="text-destructive text-sm">
                      {errors.billingAddress.message}
                    </p>
                  )}
                </div>

                <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
                  <div className="space-y-2">
                    <Label htmlFor="pinCode">Pincode</Label>
                    <Input
                      id="pinCode"
                      placeholder="110024"
                      {...register("pinCode", {
                        required: "Required",
                        pattern: {
                          value: /^[0-9]{6}$/,
                          message: "Invalid pincode",
                        },
                      })}
                    />
                    {errors.pinCode && (
                      <p className="text-destructive text-sm">
                        {errors.pinCode.message}
                      </p>
                    )}
                  </div>

                  <div className="space-y-2">
                    <Label htmlFor="contactNumber">Mobile</Label>
                    <Input
                      id="contactNumber"
                      placeholder="9991023558"
                      {...register("contactNumber", {
                        required: "Required",
                        pattern: {
                          value: /^[0-9]{10}$/,
                          message: "Invalid number",
                        },
                      })}
                    />
                    {errors.contactNumber && (
                      <p className="text-destructive text-sm">
                        {errors.contactNumber.message}
                      </p>
                    )}
                  </div>
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </CardContent>

        <CardFooter className="bg-muted/30 flex justify-between border-t py-6">
          {step === 1 ? (
            <>
              <Button
                type="button"
                variant="ghost"
                onClick={handleDiscardClick}
              >
                Cancel
              </Button>
              <Button
                type="button"
                onClick={handleNext}
                className="min-w-[100px]"
              >
                Next
                <ChevronRight className="ml-2 h-4 w-4" />
              </Button>
            </>
          ) : (
            <>
              <Button type="button" variant="ghost" onClick={handleBack}>
                <ChevronLeft className="mr-2 h-4 w-4" />
                Back
              </Button>
              <Button
                type="submit"
                disabled={isSubmitting || isSaved}
                className="min-w-[120px]"
              >
                <AnimatePresence mode="wait" initial={false}>
                  {isSubmitting ? (
                    <motion.div
                      key="saving"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                      className="flex items-center gap-2"
                    >
                      <div className="border-background h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
                      <span>Saving...</span>
                    </motion.div>
                  ) : isSaved ? (
                    <motion.div
                      key="saved"
                      initial={{ scale: 0 }}
                      animate={{ scale: 1 }}
                      exit={{ scale: 0 }}
                      className="flex items-center gap-2"
                    >
                      <Check className="h-4 w-4" />
                      <span>Saved</span>
                    </motion.div>
                  ) : (
                    <motion.span
                      key="default"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                    >
                      Save Changes
                    </motion.span>
                  )}
                </AnimatePresence>
              </Button>
            </>
          )}
        </CardFooter>
      </form>
    </Card>
  );
}

Installation

npx shadcn@latest add @billingsdk/payment-details-two

Usage

import { PaymentDetailsTwo } from "@/components/payment-details-two"
<PaymentDetailsTwo />