+
+
Failed to Load Products
@@ -179,16 +170,14 @@ const Products: React.FC = ({
if (products.length === 0) {
return (
-
-
+
+
{title}
-
+
-
+
No Products Found
@@ -201,22 +190,19 @@ const Products: React.FC = ({
}
return (
-
-
-
+
+
+
{title}
{/* Products Grid */}
-
+
{products.map((product) => (
))}
@@ -228,10 +214,11 @@ const Products: React.FC
= ({
onClick={handleLoadMore}
disabled={loadingMore}
size="lg"
+ className="font-heading"
>
{loadingMore ? (
-
+
Loading...
) : (
diff --git a/components/shopify/promo-banner.tsx b/components/shopify/promo-banner.tsx
new file mode 100644
index 0000000..57729c1
--- /dev/null
+++ b/components/shopify/promo-banner.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+} from '@/components/ui/carousel';
+
+const PROMOS = [
+ { text: 'Send a Gift Card', href: '/' },
+ { text: 'Free shipping on orders over $100', href: '/' },
+ { text: '30-day return policy', href: '/' },
+];
+
+const PromoBanner: React.FC = () => {
+ return (
+
+
+
+ {PROMOS.map((promo) => (
+
+
+
+ {promo.text}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PromoBanner;
diff --git a/components/shopify/search-dialog.tsx b/components/shopify/search-dialog.tsx
new file mode 100644
index 0000000..8ebba67
--- /dev/null
+++ b/components/shopify/search-dialog.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Command } from 'cmdk';
+import { RiSearchLine } from '@remixicon/react';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { Loader } from '@/components/ui/loader';
+import { getProducts, type Product } from '@/hooks/use-shopify-products';
+
+interface SearchDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+const SearchDialog: React.FC = ({ open, onOpenChange }) => {
+ const router = useRouter();
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [query, setQuery] = useState('');
+
+ // Load the catalog once per open; cmdk filters it as the user types
+ useEffect(() => {
+ if (!open) {
+ setQuery('');
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ getProducts({ first: 100, sortKey: 'TITLE' })
+ .then((data) => {
+ if (!cancelled) setProducts(data);
+ })
+ .catch((err) => {
+ console.error('Failed to load products for search:', err);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open]);
+
+ const handleSelect = (handle: string) => {
+ onOpenChange(false);
+ router.push(`/products/${handle}`);
+ };
+
+ return (
+
+ );
+};
+
+export default SearchDialog;
diff --git a/components/shopify/shop-footer.tsx b/components/shopify/shop-footer.tsx
new file mode 100644
index 0000000..dc848f8
--- /dev/null
+++ b/components/shopify/shop-footer.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+const Footer: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/components/shopify/shop-header.tsx b/components/shopify/shop-header.tsx
new file mode 100644
index 0000000..6f9f2bb
--- /dev/null
+++ b/components/shopify/shop-header.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import React, { useState } from 'react';
+import Link from 'next/link';
+import { useShopifyCart } from '@/hooks/use-shopify-cart';
+import config from '@/lib/config.json';
+import { RiSearchLine, RiShoppingBagLine } from '@remixicon/react';
+import SearchDialog from './search-dialog';
+
+const CartIcon: React.FC = () => {
+ const { toggleCart, itemCount } = useShopifyCart();
+
+ return (
+
+ );
+};
+
+const Header: React.FC = () => {
+ const [searchOpen, setSearchOpen] = useState(false);
+
+ return (
+
+ );
+};
+
+export default Header;
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
index 9d3232c..4a8cca4 100644
--- a/components/ui/accordion.tsx
+++ b/components/ui/accordion.tsx
@@ -1,197 +1,66 @@
-import React, { createContext, useContext, useState, useCallback } from 'react';
-import { cn } from '@/lib/utils';
+"use client"
-interface AccordionContextType {
- value: string | string[];
- onValueChange: (value: string) => void;
- type: 'single' | 'multiple';
-}
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
-const AccordionContext = createContext(
- undefined
-);
-
-interface AccordionItemContextType {
- value: string;
-}
-
-const AccordionItemContext = createContext<
- AccordionItemContextType | undefined
->(undefined);
-
-function useAccordion() {
- const context = useContext(AccordionContext);
- if (!context) {
- throw new Error('Accordion components must be used within an Accordion');
- }
- return context;
-}
-
-function useAccordionItem() {
- const context = useContext(AccordionItemContext);
- if (!context) {
- throw new Error(
- 'AccordionTrigger and AccordionContent must be used within an AccordionItem'
- );
- }
- return context;
-}
-
-interface AccordionProps {
- type?: 'single' | 'multiple';
- value?: string | string[];
- onValueChange?: (value: string | string[]) => void;
- children: React.ReactNode;
-}
+import { cn } from "@/lib/utils"
function Accordion({
- type = 'single',
- value: controlledValue,
- onValueChange,
- children,
-}: AccordionProps) {
- const [internalValue, setInternalValue] = useState(
- type === 'single' ? '' : []
- );
-
- const isControlled = controlledValue !== undefined;
- const value = isControlled ? controlledValue : internalValue;
-
- const handleValueChange = useCallback(
- (itemValue: string) => {
- if (type === 'single') {
- const newValue = value === itemValue ? '' : itemValue;
- if (!isControlled) {
- setInternalValue(newValue);
- }
- onValueChange?.(newValue);
- } else {
- const valueArray = Array.isArray(value) ? value : [];
- const newValue = valueArray.includes(itemValue)
- ? valueArray.filter((v) => v !== itemValue)
- : [...valueArray, itemValue];
- if (!isControlled) {
- setInternalValue(newValue);
- }
- onValueChange?.(newValue);
- }
- },
- [value, type, isControlled, onValueChange]
- );
+ ...props
+}: React.ComponentProps) {
+ return
+}
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
return (
-
- {children}
-
- );
-}
-
-interface AccordionItemProps {
- value: string;
- children: React.ReactNode;
- className?: string;
-}
-
-function AccordionItem({ value, children, className }: AccordionItemProps) {
- return (
-
-
- {children}
-
-
- );
-}
-
-interface AccordionTriggerProps extends React.ButtonHTMLAttributes {
- children: React.ReactNode;
+
+ )
}
function AccordionTrigger({
className,
children,
...props
-}: AccordionTriggerProps) {
- const accordion = useAccordion();
- const item = useAccordionItem();
-
- const handleClick = () => {
- accordion.onValueChange(item.value);
- };
-
- const isOpen =
- accordion.type === 'single'
- ? accordion.value === item.value
- : Array.isArray(accordion.value) && accordion.value.includes(item.value);
-
+}: React.ComponentProps) {
return (
-
-
-
- );
-}
-
-interface AccordionContentProps extends React.HTMLAttributes {
- children: React.ReactNode;
+
+
+
+ )
}
function AccordionContent({
className,
children,
...props
-}: AccordionContentProps) {
- const accordion = useAccordion();
- const item = useAccordionItem();
-
- const isOpen =
- accordion.type === 'single'
- ? accordion.value === item.value
- : Array.isArray(accordion.value) && accordion.value.includes(item.value);
-
+}: React.ComponentProps) {
return (
-
- );
+ {children}
+
+ )
}
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..4dbb919
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+import * as React from 'react';
+
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
+
+import { buttonVariants } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = 'AlertDialogHeader';
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = 'AlertDialogFooter';
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
index 3c5a3e4..1421354 100644
--- a/components/ui/alert.tsx
+++ b/components/ui/alert.tsx
@@ -1,28 +1,34 @@
import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-type AlertVariant = "default" | "destructive"
-
-const baseClasses =
- "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"
-
-const variantClasses: Record = {
- default: "bg-card text-card-foreground",
- destructive:
- "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
-}
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
function Alert({
className,
- variant = "default",
+ variant,
...props
-}: React.ComponentProps<"div"> & { variant?: AlertVariant }) {
+}: React.ComponentProps<"div"> & VariantProps) {
return (
)
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..3df3fd0
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
index 6cd2e95..a38fe5d 100644
--- a/components/ui/avatar.tsx
+++ b/components/ui/avatar.tsx
@@ -1,81 +1,109 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+"use client"
-interface AvatarProps extends React.ComponentProps<'div'> {
- size?: 'sm' | 'md' | 'lg' | 'xl';
-}
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
-const sizeClasses = {
- sm: 'size-6',
- md: 'size-8',
- lg: 'size-10',
- xl: 'size-12',
-};
-
-function Avatar({ className, size = 'md', ...props }: AvatarProps) {
- const [imageError, setImageError] = React.useState(false);
+import { cn } from "@/lib/utils"
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg"
+}) {
return (
-
- );
+ )
}
function AvatarImage({
className,
- onError,
...props
-}: React.ComponentProps<'img'>) {
- const [hasError, setHasError] = React.useState(false);
-
- const handleError = (e: React.SyntheticEvent) => {
- setHasError(true);
- onError?.(e as any);
- };
-
- if (hasError) {
- return null;
- }
-
+}: React.ComponentProps) {
return (
-
- );
-}
-
-interface AvatarFallbackProps extends React.ComponentProps<'div'> {
- children: React.ReactNode;
+ )
}
function AvatarFallback({
className,
- children,
...props
-}: AvatarFallbackProps) {
+}: React.ComponentProps) {
return (
-
- {children}
-
- );
+ />
+ )
}
-export { Avatar, AvatarImage, AvatarFallback };
-export type { AvatarProps };
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarBadge,
+ AvatarGroup,
+ AvatarGroupCount,
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index ef46f46..ba40cc1 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -1,53 +1,48 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
-type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
+import { cn } from "@/lib/utils"
-interface BadgeProps extends React.ComponentProps<'span'> {
- variant?: BadgeVariant;
- asChild?: boolean;
-}
-
-const badgeVariants: Record
= {
- default:
- 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
- secondary:
- 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
- destructive:
- 'border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
- outline:
- 'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
-};
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>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 transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
function Badge({
className,
- variant = 'default',
+ variant = "default",
asChild = false,
- children,
...props
-}: BadgeProps) {
- const baseClasses = cn(
- 'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 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 transition-colors overflow-hidden',
- '[&>svg]:size-3 [&>svg]:pointer-events-none [&>svg]:shrink-0'
- );
-
- const variantClasses = badgeVariants[variant];
-
- const finalClassName = cn(baseClasses, variantClasses, className);
-
- if (asChild && React.isValidElement(children)) {
- return React.cloneElement(children as React.ReactElement, {
- className: cn(children.props.className, finalClassName),
- ...props,
- });
- }
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
return (
-
- {children}
-
- );
+
+ )
}
-export { Badge, badgeVariants };
-export type { BadgeProps };
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
index cbe7189..eb88f32 100644
--- a/components/ui/breadcrumb.tsx
+++ b/components/ui/breadcrumb.tsx
@@ -1,140 +1,101 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
-function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
- return ;
+import { cn } from "@/lib/utils"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return
}
-function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
- );
+ )
}
-function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
- );
+ )
}
function BreadcrumbLink({
asChild,
className,
- children,
...props
-}: React.ComponentProps<'a'> & {
- asChild?: boolean;
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
}) {
- if (asChild && React.isValidElement(children)) {
- return React.cloneElement(children as React.ReactElement, {
- className: cn(
- 'hover:text-foreground transition-colors',
- children.props.className,
- className
- ),
- ...props,
- });
- }
+ const Comp = asChild ? Slot : "a"
return (
-
- {children}
-
- );
+ />
+ )
}
-function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
- );
+ )
}
function BreadcrumbSeparator({
children,
className,
...props
-}: React.ComponentProps<'li'>) {
+}: React.ComponentProps<"li">) {
return (
svg]:size-3.5', className)}
+ className={cn("[&>svg]:size-3.5", className)}
{...props}
>
- {children ?? (
-
- )}
+ {children ?? }
- );
+ )
}
function BreadcrumbEllipsis({
className,
...props
-}: React.ComponentProps<'span'>) {
+}: React.ComponentProps<"span">) {
return (
-
+
More
- );
+ )
}
export {
@@ -145,4 +106,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
-};
+}
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
index dc05be0..8600af0 100644
--- a/components/ui/button-group.tsx
+++ b/components/ui/button-group.tsx
@@ -1,97 +1,83 @@
-import React from 'react';
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
-// Utility function to combine classNames
-function cn(...classes: (string | undefined | null | false)[]): string {
- return classes.filter(Boolean).join(' ');
-}
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
-// Button group variants helper
-function getButtonGroupVariants(
- orientation: 'horizontal' | 'vertical'
-): string {
- const baseStyles =
- 'flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*="w-"])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2';
-
- const orientationStyles = {
- horizontal:
- '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
- vertical:
- 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
- };
-
- return cn(baseStyles, orientationStyles[orientation]);
-}
-
-interface ButtonGroupProps extends React.ComponentProps<'div'> {
- orientation?: 'horizontal' | 'vertical';
-}
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
function ButtonGroup({
className,
- orientation = 'horizontal',
+ orientation,
...props
-}: ButtonGroupProps) {
+}: React.ComponentProps<"div"> & VariantProps) {
return (
- );
-}
-
-interface ButtonGroupTextProps extends React.ComponentProps<'div'> {
- asChild?: boolean;
+ )
}
function ButtonGroupText({
className,
asChild = false,
...props
-}: ButtonGroupTextProps) {
- const Comp = asChild ? 'div' : 'div';
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "div"
+
return (
- );
-}
-
-interface ButtonGroupSeparatorProps extends React.ComponentProps<'div'> {
- orientation?: 'horizontal' | 'vertical';
+ )
}
function ButtonGroupSeparator({
className,
- orientation = 'vertical',
+ orientation = "vertical",
...props
-}: ButtonGroupSeparatorProps) {
- const separatorClasses =
- orientation === 'vertical' ? 'w-px h-auto' : 'h-px w-auto';
-
+}: React.ComponentProps) {
return (
-
- );
+ )
}
-export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
-export type {
- ButtonGroupProps,
- ButtonGroupTextProps,
- ButtonGroupSeparatorProps,
-};
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index d5ed246..8386d2b 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -1,70 +1,64 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
-interface ButtonProps extends React.ButtonHTMLAttributes {
- variant?:
- | 'default'
- | 'destructive'
- | 'outline'
- | 'secondary'
- | 'ghost'
- | 'link';
- size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
- children?: React.ReactNode;
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
}
-const Button = React.forwardRef(
- ({ className, variant = 'default', size = 'default', ...props }, ref) => {
- const baseClasses = cn(
- // Base styles
- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all',
- 'disabled:pointer-events-none disabled:opacity-50',
- '[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
- 'shrink-0 [&_svg]:shrink-0',
- 'outline-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'
- );
-
- const variantClasses = {
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
- destructive:
- 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
- outline:
- 'border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
- secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
- ghost:
- 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
- link: 'text-primary underline-offset-4 hover:underline',
- };
-
- const sizeClasses = {
- default: 'h-9 px-4 py-2 has-[>svg]:px-3',
- sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
- lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
- icon: 'size-9',
- 'icon-sm': 'size-8',
- 'icon-lg': 'size-10',
- };
-
- return (
-
- );
- }
-);
-
-Button.displayName = 'Button';
-
-export { Button };
-export default Button;
+export { Button, buttonVariants }
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000..9fb18ca
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-md",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+