chore: checkout clean up

This commit is contained in:
Charlie Lamb
2026-02-03 17:38:10 +00:00
parent 53c4f86181
commit 89f5743297
44 changed files with 31 additions and 2247 deletions

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
@@ -33,17 +32,11 @@
"vite-tsconfig-paths": "^6.0.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -2,13 +2,13 @@ import { LayoutGroup, motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants, listContainerVariants } from "@/lib/animations";
import { CheckoutBackground } from "./CheckoutBackground";
import { CheckoutErrorState } from "./CheckoutErrorState";
import { CheckoutHeader } from "./CheckoutHeader";
import { CheckoutSuccessState } from "./CheckoutSuccessState";
import { ConfirmSection } from "./ConfirmSection";
import { OrderSummarySection } from "./OrderSummarySection";
import { PlanSection } from "./PlanSection";
import { ConfirmSection } from "./confirm/ConfirmSection";
import { CheckoutBackground } from "./layout/CheckoutBackground";
import { CheckoutHeader } from "./layout/CheckoutHeader";
import { OrderSummarySection } from "./order-summary/OrderSummarySection";
import { PlanSection } from "./plan/PlanSection";
import { CheckoutErrorState } from "./states/CheckoutErrorState";
import { CheckoutSuccessState } from "./states/CheckoutSuccessState";
export function CheckoutContent() {
const { confirmResult, status, isSandbox } = useCheckoutContext();

View File

@@ -1,69 +0,0 @@
import type { ApiFreeTrialV2 } from "@autumn/shared";
import { Gift } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CardBackground } from "@/components/checkout/CardBackground";
import { LAYOUT_TRANSITION, listItemVariants } from "@/lib/animations";
import { formatTrialDuration } from "@/utils/trialUtils";
interface FreeTrialCardProps {
freeTrial: ApiFreeTrialV2;
trialAvailable: boolean;
}
export function FreeTrialCard({ freeTrial, trialAvailable }: FreeTrialCardProps) {
const duration = formatTrialDuration({
duration_type: freeTrial.duration_type,
duration_length: freeTrial.duration_length,
});
return (
<motion.div
layout
layoutId="free-trial-card"
variants={listItemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ layout: LAYOUT_TRANSITION, opacity: { duration: 0.2 } }}
className="rounded-lg border border-border overflow-hidden"
>
<CardBackground>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-background/50">
<div className="flex items-center gap-2">
<Gift
className="h-4 w-4 text-blue-500 dark:text-blue-400"
weight="bold"
/>
<span className="text-sm font-medium text-foreground">
Free Trial
</span>
</div>
<span className="text-sm font-medium text-foreground">
{duration}
</span>
</div>
{/* Content */}
<div className="px-3 py-2.5">
{trialAvailable ? (
<>
<p className="text-sm text-muted-foreground">
Try free for {duration} before subscribing
</p>
<p className="text-xs text-muted-foreground mt-1">
{freeTrial.card_required
? "Card required to start"
: "No payment required to start"}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
Trial already used
</p>
)}
</div>
</CardBackground>
</motion.div>
);
}

View File

@@ -1,22 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton that matches FreeTrialCard structure */
export function FreeTrialCardSkeleton() {
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-background/50">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="h-4 w-14" />
</div>
{/* Content */}
<div className="px-3 py-2.5 space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-36" />
</div>
</div>
);
}

View File

@@ -1,148 +0,0 @@
import type { PreviewLineItem } from "@autumn/shared";
import { Clock, Minus, Plus } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { FAST_TRANSITION, LAYOUT_TRANSITION, listItemVariants } from "@/lib/animations";
import { cn } from "@/lib/utils";
import { formatAmount } from "@/utils/formatUtils";
import { CardBackground } from "@/components/checkout/CardBackground";
type PlanChangeType = "incoming" | "outgoing";
interface PlanGroupCardProps {
planId: string;
planName: string;
items: PreviewLineItem[];
currency: string;
index: number;
type: PlanChangeType;
}
export function PlanGroupCard({
planId,
planName,
items,
currency,
index,
type,
}: PlanGroupCardProps) {
const Icon = type === "outgoing" ? Minus : Plus;
const groupTotal = items.reduce((sum, item) => sum + item.amount, 0);
// Check if all items in this group are deferred for trial
const allDeferred =
items.length > 0 && items.every((item) => item.deferred_for_trial);
// Sort items so base price appears first
const sortedItems = [...items].sort((a, b) => {
if (a.is_base && !b.is_base) return -1;
if (!a.is_base && b.is_base) return 1;
return 0;
});
return (
<motion.div
layout
layoutId={`plan-group-${planId}`}
variants={listItemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
layout: LAYOUT_TRANSITION,
opacity: { duration: 0.2, delay: index * 0.03 },
}}
className="rounded-lg border border-border overflow-hidden"
>
<CardBackground>
{/* After trial banner */}
{allDeferred && (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/50 border-b text-xs text-muted-foreground">
<Clock className="h-3 w-3" weight="bold" />
<span>Charged after trial ends</span>
</div>
)}
{/* Plan header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-background/50">
<div className="flex items-center gap-2">
<Icon
className={cn(
"h-4 w-4",
type === "outgoing"
? "dark:text-red-400/60 text-red-500"
: "dark:text-emerald-400/60 text-emerald-500"
)}
weight="bold"
/>
<span className="text-sm font-semibold text-foreground">
{planName}
</span>
</div>
<span className="text-sm font-semibold tabular-nums text-foreground">
{formatAmount(groupTotal, currency)}
</span>
</div>
{/* Line items for this plan */}
<div className="px-3">
{sortedItems.length === 0 ? (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{type === "outgoing" ? "No charges" : "Free"}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{formatAmount(0, currency)}
</span>
</div>
) : (
sortedItems.map((item, itemIndex) => (
<div key={`${item.title}-${itemIndex}`}>
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{item.is_base ? "Base Price" : item.title}
</span>
{!item.is_base && item.total_quantity > 1 && (
<motion.span
key={item.total_quantity}
className="text-xs text-muted-foreground/70"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={FAST_TRANSITION}
>
x{item.total_quantity}
</motion.span>
)}
</div>
<div className="flex items-center gap-2">
{/* Show "After trial" for individual deferred items (only if not all are deferred) */}
{item.deferred_for_trial && !allDeferred && (
<span className="flex items-center gap-1 text-xs text-muted-foreground/70">
<Clock className="h-3 w-3" weight="bold" />
After trial
</span>
)}
<motion.span
key={item.amount}
className="text-xs tabular-nums text-muted-foreground"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(item.amount, currency)}
</motion.span>
</div>
</div>
{itemIndex < sortedItems.length - 1 && (
<Separator className="opacity-50" />
)}
</div>
))
)}
</div>
</CardBackground>
</motion.div>
);
}

View File

@@ -1,24 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton that matches PlanGroupCard structure */
export function PlanGroupCardSkeleton() {
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-background/50">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-16" />
</div>
{/* Line item */}
<div className="px-3 py-2.5">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { FAST_TRANSITION, STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { BottomSectionSkeleton } from "./BottomSectionSkeleton";
import { CheckoutFooter } from "./CheckoutFooter";
import { CheckoutFooter } from "../layout/CheckoutFooter";
function getButtonText({
isPending,

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { motion } from "motion/react";
import { BackgroundBeams } from "@/components/bg/background-beams";
import { SandboxBanner } from "@/components/checkout/SandboxBanner";
import { SandboxBanner } from "@/components/checkout/layout/SandboxBanner";
import { SLOW_TRANSITION, SPRING_TRANSITION } from "@/lib/animations";
interface CheckoutBackgroundProps {

View File

@@ -1,9 +1,9 @@
import type { PreviewLineItem } from "@autumn/shared";
import { motion } from "motion/react";
import { useMemo } from "react";
import { CardBackground } from "@/components/checkout/CardBackground";
import { FreeTrialSection } from "@/components/checkout/FreeTrialSection";
import { PlanGroupSection } from "@/components/checkout/PlanGroupSection";
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { PlanGroupSection } from "@/components/checkout/plan/PlanGroupSection";
import { FreeTrialSection } from "@/components/checkout/trial/FreeTrialSection";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { LAYOUT_TRANSITION } from "@/lib/animations";

View File

@@ -4,7 +4,7 @@ import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { FAST_TRANSITION, STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { OrderSummary } from "./OrderSummary";
import { OrderSummarySkeleton } from "./OrderSummarySkeleton";
import { SectionHeader } from "./SectionHeader";
import { SectionHeader } from "../shared/SectionHeader";
export function OrderSummarySection() {
const { status } = useCheckoutContext();

View File

@@ -4,7 +4,7 @@ import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { PlanSelectionCard } from "./PlanSelectionCard";
import { PlanSelectionCardSkeleton } from "./PlanSelectionCardSkeleton";
import { SectionHeader } from "./SectionHeader";
import { SectionHeader } from "../shared/SectionHeader";
export function PlanSection() {
const { incoming, status } = useCheckoutContext();

View File

@@ -12,8 +12,8 @@ import {
listItemVariants,
} from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { CardBackground } from "@/components/checkout/CardBackground";
import { QuantityInput } from "./QuantityInput";
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { QuantityInput } from "../shared/QuantityInput";
function categorizeFeatures(features: ApiPlanFeature[]): {
prepaid: ApiPlanFeature[];

View File

@@ -1,4 +1,4 @@
import { CardBackground } from "@/components/checkout/CardBackground";
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";

View File

@@ -1,6 +1,6 @@
import { WarningIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/CheckoutBackground";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutErrorState({ message }: { message: string }) {

View File

@@ -1,7 +1,7 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { CheckIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/CheckoutBackground";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutSuccessState({

View File

@@ -1,59 +0,0 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import {
LAYOUT_TRANSITION,
SKELETON_CYCLE_INTERVAL,
skeletonItemVariants,
} from "@/lib/animations";
interface CyclingSkeletonListProps {
renderItem: (index: number) => React.ReactNode;
minItems?: number;
maxItems?: number;
interval?: number;
}
/**
* Renders a list of skeleton items that cycles between min and max count.
* Items animate in/out with slide-down + fade effect.
*/
export function CyclingSkeletonList({
renderItem,
minItems = 1,
maxItems = 2,
interval = SKELETON_CYCLE_INTERVAL,
}: CyclingSkeletonListProps) {
const [itemCount, setItemCount] = useState(minItems);
useEffect(() => {
const timer = setInterval(() => {
setItemCount((prev) => (prev >= maxItems ? minItems : prev + 1));
}, interval);
return () => clearInterval(timer);
}, [minItems, maxItems, interval]);
return (
<motion.div
layout
transition={{ layout: LAYOUT_TRANSITION }}
className="flex flex-col gap-3"
>
<AnimatePresence mode="popLayout" initial={false}>
{Array.from({ length: itemCount }).map((_, index) => (
<motion.div
key={`skeleton-item-${index}`}
layout
variants={skeletonItemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ layout: LAYOUT_TRANSITION }}
>
{renderItem(index)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -1,70 +0,0 @@
import { type HTMLMotionProps, motion } from "motion/react";
import { forwardRef } from "react";
import { LAYOUT_TRANSITION } from "@/lib/animations";
type AnimatedLayoutProps = HTMLMotionProps<"div"> & {
/** Unique ID for layout animations - elements with matching IDs animate between each other */
layoutId?: string;
};
/**
* A div that smoothly animates size/position changes.
* Uses a gentle spring transition optimized for skeleton-to-content morphing.
*/
export const AnimatedLayout = forwardRef<HTMLDivElement, AnimatedLayoutProps>(
({ children, layoutId, transition, ...props }, ref) => (
<motion.div
ref={ref}
layout
layoutId={layoutId}
transition={{
layout: LAYOUT_TRANSITION,
...transition,
}}
{...props}
>
{children}
</motion.div>
),
);
AnimatedLayout.displayName = "AnimatedLayout";
/** Pre-configured animated container for cards with layout animations. */
export const AnimatedCard = forwardRef<HTMLDivElement, AnimatedLayoutProps>(
({ children, layoutId, transition, ...props }, ref) => (
<motion.div
ref={ref}
layout
layoutId={layoutId}
transition={{
layout: LAYOUT_TRANSITION,
...transition,
}}
{...props}
>
{children}
</motion.div>
),
);
AnimatedCard.displayName = "AnimatedCard";
/**
* Animated list item that works within AnimatedLayout containers.
* Automatically participates in layout animations.
*/
export const AnimatedListItem = forwardRef<HTMLDivElement, AnimatedLayoutProps>(
({ children, transition, ...props }, ref) => (
<motion.div
ref={ref}
layout
transition={{
layout: LAYOUT_TRANSITION,
...transition,
}}
{...props}
>
{children}
</motion.div>
),
);
AnimatedListItem.displayName = "AnimatedListItem";

View File

@@ -1,175 +0,0 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -1,48 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ className, variant })),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -1,290 +0,0 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { CaretDownIcon, XIcon, CheckIcon } from "@phosphor-icons/react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<CaretDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 max-h-72 min-w-36 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0 overflow-y-auto overscroll-contain",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn("text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex", className)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn("dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1", className)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn(
"min-w-16 flex-1 outline-none",
className
)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -1,254 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 w-auto", className)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: MenuPrimitive.CheckboxItem.Props) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: MenuPrimitive.RadioItem.Props) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,227 +0,0 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn("gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"gap-5 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col",
className
)}
{...props}
/>
)
}
const fieldVariants = cva("data-[invalid=true]:text-destructive gap-2 group/field flex w-full", {
variants: {
orientation: {
vertical:
"flex-col [&>*]:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
})
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"has-data-checked:bg-primary/5 has-data-checked:border-primary dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative", className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -1,147 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end":
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"gap-2 text-sm shadow-none flex items-center",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -1,20 +0,0 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,200 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"gap-4 has-[[data-size=sm]]:gap-2.5 has-[[data-size=xs]]:gap-2 group/item-group flex w-full flex-col",
className,
)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
);
}
const itemVariants = cva(
"[a]:hover:bg-muted rounded-lg border text-sm w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "bg-card border border-border shadow-sm",
},
size: {
default: "gap-3 px-4 py-3.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props,
),
render,
state: {
slot: "item",
variant,
size,
},
});
}
const itemMediaVariants = cva(
"gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"gap-1 group-data-[size=xs]/item:gap-0 flex flex-1 flex-col [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"gap-2 text-sm leading-snug font-medium underline-offset-4 line-clamp-1 flex w-fit items-center",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground text-left text-sm leading-normal group-data-[size=xs]/item:text-xs [&>a:hover]:text-primary line-clamp-2 font-normal [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("gap-2 flex items-center", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"gap-2 flex basis-full items-center justify-between",
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"gap-2 flex basis-full items-center justify-between",
className,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,191 +0,0 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { CaretDownIcon, CheckIcon, CaretUpIcon } from "@phosphor-icons/react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<CaretDownIcon className="text-muted-foreground size-4 pointer-events-none" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
{...props}
>
<CaretUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
{...props}
>
<CaretDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
/**
* Hook to detect user's reduced motion preference.
* Returns true if the user prefers reduced motion.
*/
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}
/**
* Returns animation props that respect reduced motion.
* When reduced motion is preferred, animations are instant.
*/
export function useAnimationProps(reducedMotion: boolean) {
if (reducedMotion) {
return {
initial: false,
animate: undefined,
exit: undefined,
transition: { duration: 0 },
};
}
return {};
}

View File

@@ -45,31 +45,6 @@ export const CROSSFADE_TRANSITION: Transition = {
ease: [0.4, 0, 0.2, 1],
};
/** Skeleton cycling configuration */
export const SKELETON_CYCLE_INTERVAL = 1200; // ms between state changes (1 item -> 2 items -> 1 item)
export const SKELETON_ITEM_DURATION = 0.25; // seconds for enter/exit animations
/** Skeleton item slide-down + fade animation */
export const skeletonItemVariants: Variants = {
initial: { opacity: 0, y: -10 },
animate: {
opacity: 1,
y: 0,
transition: {
duration: SKELETON_ITEM_DURATION,
ease: [0.4, 0, 0.2, 1],
},
},
exit: {
opacity: 0,
y: 5,
transition: {
duration: SKELETON_ITEM_DURATION * 0.8,
ease: [0.4, 0, 1, 1],
},
},
};
/** Gentle spring for success animations */
export const GENTLE_SPRING: Transition = {
type: "spring",
@@ -79,28 +54,11 @@ export const GENTLE_SPRING: Transition = {
};
/** Stagger settings for list animations */
export const STAGGER_CHILDREN = {
const STAGGER_CHILDREN = {
staggerChildren: 0.05,
delayChildren: 0.08,
};
/** Faster stagger for smaller lists */
export const FAST_STAGGER = {
staggerChildren: 0.03,
delayChildren: 0.05,
};
// ============================================
// Variant Presets
// ============================================
/** Simple fade in/out */
export const fadeVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
/** Fade with upward slide (8px) */
export const fadeUpVariants: Variants = {
initial: { opacity: 0, y: 8 },
@@ -108,27 +66,6 @@ export const fadeUpVariants: Variants = {
exit: { opacity: 0, y: -4 },
};
/** Fade with downward slide */
export const fadeDownVariants: Variants = {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 4 },
};
/** Scale with fade (for modals, success states) */
export const scaleVariants: Variants = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.98 },
};
/** Larger scale animation for celebration effects */
export const celebrateVariants: Variants = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
};
/** Container variant with staggered children */
export const listContainerVariants: Variants = {
initial: { opacity: 0 },
@@ -139,16 +76,6 @@ export const listContainerVariants: Variants = {
exit: { opacity: 0 },
};
/** Fast container variant for smaller lists */
export const fastListContainerVariants: Variants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: FAST_STAGGER,
},
exit: { opacity: 0 },
};
/** List item variant (used with container) */
export const listItemVariants: Variants = {
initial: { opacity: 0, y: 10 },
@@ -163,116 +90,3 @@ export const listItemVariants: Variants = {
transition: FAST_TRANSITION,
},
};
/** Skeleton shimmer variant */
export const skeletonVariants: Variants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: { duration: 0.2 },
},
exit: {
opacity: 0,
transition: { duration: 0.15 },
},
};
/** Number counter direction variants */
export const numberUpVariants: Variants = {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
};
export const numberDownVariants: Variants = {
initial: { opacity: 0, y: -10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 10 },
};
/** Button press animation */
export const buttonPressVariants: Variants = {
idle: { scale: 1 },
pressed: { scale: 0.98 },
};
/** Card hover animation */
export const cardHoverVariants: Variants = {
idle: { scale: 1 },
hovered: { scale: 1.01 },
};
/** Checkmark draw animation */
export const checkmarkVariants: Variants = {
initial: { pathLength: 0, opacity: 0 },
animate: {
pathLength: 1,
opacity: 1,
transition: {
pathLength: { duration: 0.3, ease: "easeOut" },
opacity: { duration: 0.1 },
},
},
};
/** Width expansion (for separators, progress bars) */
export const widthExpandVariants: Variants = {
initial: { scaleX: 0, originX: 0 },
animate: {
scaleX: 1,
transition: STANDARD_TRANSITION,
},
};
/** Height collapse/expand */
export const collapseVariants: Variants = {
initial: { height: 0, opacity: 0 },
animate: {
height: "auto",
opacity: 1,
transition: {
height: STANDARD_TRANSITION,
opacity: { duration: 0.2, delay: 0.1 },
},
},
exit: {
height: 0,
opacity: 0,
transition: {
height: STANDARD_TRANSITION,
opacity: { duration: 0.1 },
},
},
};
// ============================================
// Helper functions
// ============================================
/** Creates a delayed variant */
export function withDelay<T extends Variants>(
variants: T,
delay: number,
): Variants {
return {
...variants,
animate: {
...(typeof variants.animate === "object" ? variants.animate : {}),
transition: {
...((typeof variants.animate === "object" &&
"transition" in variants.animate
? variants.animate.transition
: {}) as object),
delay,
},
},
};
}
/** Creates transition with custom duration */
export function withDuration(duration: number): Transition {
return {
duration,
ease: [0.32, 0.72, 0, 1],
};
}

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { CheckoutBackground } from "./components/checkout/CheckoutBackground";
import { CheckoutBackground } from "./components/checkout/layout/CheckoutBackground";
import { ThemeProvider } from "./components/theme-provider";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card";
import { useDevThemeToggle } from "./hooks/useDevThemeToggle";

View File

@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { CheckoutContent } from "@/components/checkout/CheckoutContent";
import { CheckoutErrorState } from "@/components/checkout/CheckoutErrorState";
import { CheckoutErrorState } from "@/components/checkout/states/CheckoutErrorState";
import { CheckoutProvider } from "@/contexts/CheckoutContext";
export function CheckoutPage() {

View File

@@ -7,16 +7,8 @@ export function formatAmount(amount: number, currency: string): string {
}).format(amount);
}
export function formatDate(timestamp: number): string {
return format(new Date(timestamp * 1000), "do MMMM yyyy");
}
/** Formats a millisecond timestamp to a readable date (e.g., "3rd February") */
export function formatPeriodDate(timestampMs: number): string {
return format(new Date(timestampMs), "do MMMM");
}
/** Formats a period range from millisecond timestamps (e.g., "Feb 18 Mar 4") */
/** Formats a period range from millisecond timestamps (e.g., "3rd February 4th March") */
export function formatPeriodRange(startMs: number, endMs: number): string {
return `${formatPeriodDate(startMs)} ${formatPeriodDate(endMs)}`;
const formatDate = (ms: number) => format(new Date(ms), "do MMMM");
return `${formatDate(startMs)} ${formatDate(endMs)}`;
}

View File

@@ -36,6 +36,12 @@
"scripts": {
"entry": ["**/*.{ts,js}"],
"project": ["**/*.{ts,js}"]
},
"apps/checkout": {
"entry": ["src/main.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/components/ui/**", "src/hooks/**"],
"ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"]
}
},
"ignoreBinaries": ["infisical", "lsof", "serve"]