ui
Cart Line Item
Cart line item primitive.
Installation
pnpm dlx shadcn@latest add https://registry.circle.health/r/cart-line-item.jsonRegistry 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/cart-line-item.json