ui

Pricing Table

Pricing table primitives.

Installation

pnpm dlx shadcn@latest add https://registry.circle.health/r/pricing-table.json

Registry dependencies

Component

"use client";

import * as React from "react";

import { cn } from "@/registry/berlin/lib/utils";
import { Badge, type BadgeProps } from "@/registry/berlin/ui/badge";
import { Button, type ButtonProps } from "@/registry/berlin/ui/button";
import { TextBox } from "@/registry/berlin/ui/form";

function PlusIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path d="M8 3.5v9m4.5-4.5h-9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function MinusIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path d="M3.5 8h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function BinIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path d="M3.5 5.5h9m-7 0V4.25A1.25 1.25 0 0 1 6.75 3h2.5A1.25 1.25 0 0 1 10.5 4.25V5.5m-5.5 0 0.5 6A1.5 1.5 0 0 0 7 13h2a1.5 1.5 0 0 0 1.5-1.5l.5-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

interface PricingDisplayProps {
  finalPrice?: string;
  initialPrice?: string;
  savings?: string;
}

export interface ProductItemProps extends React.HTMLAttributes<HTMLDivElement> {
  image?: string;
  title: string;
  pricing?: PricingDisplayProps;
}

function PricingDisplay({ finalPrice, initialPrice, savings }: PricingDisplayProps) {
  return (
    <div className="flex flex-wrap items-center gap-2 text-sm leading-6">
      {finalPrice ? <p className="font-semibold text-foreground">{finalPrice}</p> : null}
      {initialPrice ? <p className="text-muted-foreground line-through">{initialPrice}</p> : null}
      {savings ? <p className="font-medium text-[var(--Blue)]">{savings}</p> : null}
    </div>
  );
}

export function ProductItem({
  image,
  title,
  pricing,
  children,
  className,
  ...props
}: ProductItemProps) {
  return (
    <div className={cn("flex items-center gap-4", className)} {...props}>
      {image ? <img className="size-16 rounded-xl object-cover" src={image} alt={title} /> : null}
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <div className="text-base font-medium leading-6 text-foreground">{title}</div>
        {children ? <div className="text-sm leading-6 text-muted-foreground">{children}</div> : null}
        {pricing ? <PricingDisplay {...pricing} /> : null}
      </div>
    </div>
  );
}

export interface ProductCardProps extends ProductItemProps {
  badge?: BadgeProps;
  button?: ButtonProps;
}

export function ProductCard({ badge, button, className, ...props }: ProductCardProps) {
  return (
    <div className={cn("space-y-4 rounded-3xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]", className)}>
      {badge ? (
        <div className="flex justify-start">
          <Badge {...badge} />
        </div>
      ) : null}
      <ProductItem {...props} />
      {button ? <Button size="small" theme="light" {...button} /> : null}
    </div>
  );
}

type TableRowVariant = "highlight" | "default" | "total" | "totalLarge";

export interface TableRowProps extends React.HTMLAttributes<HTMLDivElement> {
  description: string;
  amount: string;
  variant?: TableRowVariant;
  truncateValue?: boolean;
}

export function PricingTableRow({
  description,
  amount,
  variant = "default",
  truncateValue = false,
  className,
  ...props
}: TableRowProps) {
  return (
    <div
      className={cn(
        "flex items-center justify-between gap-4 py-3 text-sm leading-6 text-foreground",
        variant === "highlight" && "rounded-xl bg-secondary px-3 font-medium",
        variant === "total" && "border-t border-border pt-4 font-semibold",
        variant === "totalLarge" && "border-t border-border pt-4 text-base font-semibold",
        className
      )}
      {...props}
    >
      <div>{description}</div>
      <div className={cn(truncateValue && "max-w-[140px] truncate text-right")}>{amount}</div>
    </div>
  );
}

export interface PricingTableProps extends React.HTMLAttributes<HTMLDivElement> {
  lines?: TableRowProps[];
  children?: React.ReactNode;
}

export function PricingTable({ lines, className, children, ...props }: PricingTableProps) {
  return (
    <div className={cn("rounded-3xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]", className)} {...props}>
      {children ??
        lines?.map((line, index) => (
          <PricingTableRow key={index} {...line} />
        ))}
    </div>
  );
}

export interface QuantityAdjustorProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  disabled?: boolean;
  value: number;
  onChange: (value: number) => void;
  onRemove?: () => void;
  min?: number;
  max?: number;
  renderRemoved?: (restore: () => void) => React.ReactNode;
}

export function QuantityAdjustor({
  disabled = false,
  value,
  onChange,
  onRemove,
  min = 0,
  max = Infinity,
  className,
  renderRemoved,
  ...props
}: QuantityAdjustorProps) {
  const restore = React.useCallback(() => onChange(1), [onChange]);

  if (value === 0) {
    return (
      <>
        {renderRemoved ? (
          renderRemoved(restore)
        ) : (
          <button type="button" onClick={restore} className="rounded-full border border-dashed border-border px-4 py-2 text-sm text-[var(--Blue)]">
            Add Item
          </button>
        )}
      </>
    );
  }

  return (
    <div
      className={cn(
        "inline-flex h-10 items-center rounded-full border border-[var(--Grey)]/30 px-1 text-sm text-[var(--Grey)]",
        disabled && "opacity-50",
        className
      )}
      {...props}
    >
      <button
        type="button"
        disabled={disabled}
        onClick={() => {
          if (value <= min) return;
          const next = value - 1;
          if (next === 0) {
            onRemove ? onRemove() : onChange(0);
            return;
          }
          onChange(next);
        }}
        className="inline-flex size-8 items-center justify-center rounded-full"
      >
        {value > 1 ? <MinusIcon className="size-4" /> : <BinIcon className="size-4" />}
      </button>
      <div className="min-w-8 text-center">{value}</div>
      <button
        type="button"
        disabled={disabled || value >= max}
        onClick={() => onChange(Math.min(value + 1, max))}
        className="inline-flex size-8 items-center justify-center rounded-full"
      >
        <PlusIcon className="size-4" />
      </button>
    </div>
  );
}

export interface CartLineItemProps extends QuantityAdjustorProps {
  product: ProductItemProps;
}

export function CartLineItem({ product, className, ...props }: CartLineItemProps) {
  return (
    <div className={cn("flex items-center justify-between gap-4 rounded-3xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]", className)}>
      <ProductItem {...product} className="flex-1" />
      <QuantityAdjustor {...props} />
    </div>
  );
}

export interface SubmitButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  name?: string;
  text: string;
  state?: "clear" | "error" | "success" | "default" | "notEmpty";
}

function SubmitButton({ text, className, disabled, ...props }: SubmitButtonProps) {
  return (
    <button
      type="submit"
      disabled={disabled}
      className={cn(
        "rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60",
        className
      )}
      {...props}
    >
      {text}
    </button>
  );
}

export interface DiscountInputProps {
  input: {
    label: string;
    name: string;
    placeholder: string;
  };
  button: SubmitButtonProps;
  className?: string;
  initialValues?: Record<string, string>;
  asyncCheck: (data: Record<string, string>) => Promise<{ message?: string } | unknown>;
}

export function DiscountInput({
  input,
  button,
  className,
  initialValues,
  asyncCheck
}: DiscountInputProps) {
  const [value, setValue] = React.useState(initialValues?.[input.name] ?? "");
  const [status, setStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
  const [message, setMessage] = React.useState<string | null>(null);

  return (
    <form
      className={cn("flex items-start gap-3", className)}
      onSubmit={async (event) => {
        event.preventDefault();
        setStatus("loading");
        setMessage(null);
        try {
          const result = await asyncCheck({ [input.name]: value });
          setStatus("success");
          setMessage((result as { message?: string })?.message ?? "Discount applied");
        } catch (error) {
          setStatus("error");
          setMessage(error instanceof Error ? error.message : "Could not apply discount");
        }
      }}
    >
      <div className="flex-1">
        <TextBox
          name={input.name}
          label={input.label}
          placeholder={input.placeholder}
          value={value}
          error={status === "error" ? message ?? undefined : undefined}
          onChange={(event) => setValue(event.target.value)}
        />
        {status === "success" && message ? <div className="mt-2 text-sm text-[var(--Success)]">{message}</div> : null}
      </div>
      <SubmitButton {...button} text={status === "loading" ? "Validating..." : button.text} disabled={status === "loading" || !value} />
    </form>
  );
}

export interface InputDiscountProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "defaultValue" | "name" | "onChange" | "value"> {
  name: string;
  error?: string;
  label?: string;
  placeholder?: string;
  successMessage?: string;
  errorMessage?: string;
  validCode?: string;
  buttonText?: string;
  buttonValidatingText?: string;
  onApplyCode?: (code: string, isValid: boolean) => void;
}

export function InputDiscount({
  name,
  error,
  label,
  placeholder,
  successMessage = "Discount code applied successfully!",
  errorMessage = "Invalid discount code",
  validCode = "VALID",
  buttonText = "Apply",
  buttonValidatingText = "Validating...",
  onApplyCode,
  className,
  ...props
}: InputDiscountProps) {
  const [inputValue, setInputValue] = React.useState("");
  const [isValidating, setIsValidating] = React.useState(false);
  const [result, setResult] = React.useState<{ success?: string; error?: string }>({});

  async function handleApply() {
    if (!inputValue) return;
    setIsValidating(true);
    setResult({});
    await new Promise((resolve) => setTimeout(resolve, 400));
    const isValid = inputValue.toUpperCase() === validCode.toUpperCase();
    const next = isValid ? { success: successMessage } : { error: errorMessage };
    setResult(next);
    setIsValidating(false);
    onApplyCode?.(inputValue, isValid);
  }

  return (
    <div className={cn("space-y-3", className)}>
      <div className="relative">
        <TextBox
          {...props}
          name={name}
          label={label}
          placeholder={placeholder}
          value={inputValue}
          error={error || result.error}
          onChange={(event) => {
            setInputValue(event.target.value);
            if (result.success || result.error) setResult({});
          }}
        />
        <button
          type="button"
          onClick={handleApply}
          disabled={isValidating || !inputValue}
          className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
        >
          {isValidating ? buttonValidatingText : buttonText}
        </button>
      </div>
      {result.success ? <div className="text-sm text-[var(--Success)]">{result.success}</div> : null}
    </div>
  );
}
Raw registry JSON/r/pricing-table.json