From a51f82e6813524047a4d5a879d6f3673eaa4f223 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sun, 19 Apr 2026 11:28:23 -0400 Subject: [PATCH] Initial commit --- .env.local | 1 + .gitignore | 4 + app/api/hello/route.ts | 5 + app/collections/[handle]/page.tsx | 55 ++ app/collections/page.tsx | 36 + app/error.tsx | 25 + app/globals.css | 91 ++ app/layout.tsx | 41 + app/not-found.tsx | 20 + app/page.tsx | 11 + app/products/[handle]/page.tsx | 197 +++++ app/providers.tsx | 12 + components/About.tsx | 13 + components/Theme.tsx | 123 +++ components/magicui/aurora-text.tsx | 40 + components/magicui/blur-fade.tsx | 63 ++ components/shopify/cart-drawer.tsx | 217 +++++ components/shopify/collection-card.tsx | 64 ++ components/shopify/collection-detail.tsx | 108 +++ components/shopify/collections.tsx | 104 +++ components/shopify/product-card.tsx | 139 +++ components/shopify/product-detail.tsx | 3 + components/shopify/product-detail/index.tsx | 211 +++++ .../product-detail/product-detail-gallery.tsx | 66 ++ .../product-detail/product-detail-info.tsx | 158 ++++ .../product-recommendations.tsx | 59 ++ components/shopify/products.tsx | 239 ++++++ components/shopify/shop-footer.tsx | 24 + components/shopify/shop-header.tsx | 68 ++ components/ui/accordion.tsx | 197 +++++ components/ui/alert.tsx | 60 ++ components/ui/avatar.tsx | 81 ++ components/ui/badge.tsx | 53 ++ components/ui/breadcrumb.tsx | 148 ++++ components/ui/button-group.tsx | 97 +++ components/ui/button.tsx | 70 ++ components/ui/card.tsx | 92 ++ components/ui/carousel.tsx | 245 ++++++ components/ui/dialog.tsx | 266 ++++++ components/ui/empty.tsx | 105 +++ components/ui/input-otp.tsx | 86 ++ components/ui/input.tsx | 21 + components/ui/item.tsx | 213 +++++ components/ui/pagination.tsx | 127 +++ components/ui/progress.tsx | 44 + components/ui/select.tsx | 287 +++++++ components/ui/sheet.tsx | 306 +++++++ components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 23 + components/ui/spinner.tsx | 38 + components/ui/switch.tsx | 74 ++ components/ui/table.tsx | 113 +++ components/ui/tabs.tsx | 143 +++ components/ui/textarea.tsx | 18 + contexts/shopify-context.tsx | 267 ++++++ graphql/cart.js | 137 +++ graphql/collections.js | 61 ++ graphql/products.js | 116 +++ hooks/use-shopify-cart.ts | 165 ++++ hooks/use-shopify-collections.ts | 127 +++ hooks/use-shopify-products.ts | 205 +++++ lib/config.json | 20 + lib/utils.ts | 18 + next.config.mjs | 7 + package.json | 31 + postcss.config.js | 7 + services/shopify/client.js | 48 ++ tsconfig.json | 49 ++ yarn.lock | 812 ++++++++++++++++++ 69 files changed, 7187 insertions(+) create mode 100644 .env.local create mode 100644 .gitignore create mode 100644 app/api/hello/route.ts create mode 100644 app/collections/[handle]/page.tsx create mode 100644 app/collections/page.tsx create mode 100644 app/error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/products/[handle]/page.tsx create mode 100644 app/providers.tsx create mode 100644 components/About.tsx create mode 100644 components/Theme.tsx create mode 100644 components/magicui/aurora-text.tsx create mode 100644 components/magicui/blur-fade.tsx create mode 100644 components/shopify/cart-drawer.tsx create mode 100644 components/shopify/collection-card.tsx create mode 100644 components/shopify/collection-detail.tsx create mode 100644 components/shopify/collections.tsx create mode 100644 components/shopify/product-card.tsx create mode 100644 components/shopify/product-detail.tsx create mode 100644 components/shopify/product-detail/index.tsx create mode 100644 components/shopify/product-detail/product-detail-gallery.tsx create mode 100644 components/shopify/product-detail/product-detail-info.tsx create mode 100644 components/shopify/product-detail/product-recommendations.tsx create mode 100644 components/shopify/products.tsx create mode 100644 components/shopify/shop-footer.tsx create mode 100644 components/shopify/shop-header.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 contexts/shopify-context.tsx create mode 100644 graphql/cart.js create mode 100644 graphql/collections.js create mode 100644 graphql/products.js create mode 100644 hooks/use-shopify-cart.ts create mode 100644 hooks/use-shopify-collections.ts create mode 100644 hooks/use-shopify-products.ts create mode 100644 lib/config.json create mode 100644 lib/utils.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 services/shopify/client.js create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..7f70982 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7aa21cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules/* +.next +next-env.d.ts diff --git a/app/api/hello/route.ts b/app/api/hello/route.ts new file mode 100644 index 0000000..3c2ac37 --- /dev/null +++ b/app/api/hello/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export async function POST(request: Request) { + return NextResponse.json({ message: 'Hello, World' }); +} diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx new file mode 100644 index 0000000..7a6fc0d --- /dev/null +++ b/app/collections/[handle]/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import CollectionDetail from '@/components/shopify/collection-detail'; +import { useCollectionProducts } from '@/hooks/use-shopify-collections'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +export default function CollectionPage() { + const params = useParams(); + const handle = params?.handle as string; + const { collection } = useCollectionProducts(handle); + + // Format title from handle as fallback + const formattedTitle = handle + ? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + : 'Collection'; + + const title = collection?.title || formattedTitle; + + return ( +
+
+ + + + + Home + + + + + + Collections + + + + + {title} + + + +
+ +
+ ); +} diff --git a/app/collections/page.tsx b/app/collections/page.tsx new file mode 100644 index 0000000..6ee557c --- /dev/null +++ b/app/collections/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import Collections from '@/components/shopify/collections'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +export default function CollectionsPage() { + return ( +
+
+ + + + + Home + + + + + Collections + + + +
+ +
+ ); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..b3805e1 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+
+ Something went wrong +
+
+
+ {error.message} +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..39818a0 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,91 @@ +@import 'tailwindcss'; + +@theme { + /* Background and foreground */ + --color-background: hsl(0 0% 100%); + --color-foreground: hsl(222.2 84% 4.9%); + + /* Card */ + --color-card: hsl(0 0% 100%); + --color-card-foreground: hsl(222.2 84% 4.9%); + + /* Popover */ + --color-popover: hsl(0 0% 100%); + --color-popover-foreground: hsl(222.2 84% 4.9%); + + /* Primary */ + --color-primary: hsl(222.2 47.4% 11.2%); + --color-primary-foreground: hsl(210 40% 98%); + + /* Secondary */ + --color-secondary: hsl(210 40% 96.1%); + --color-secondary-foreground: hsl(222.2 47.4% 11.2%); + + /* Muted */ + --color-muted: hsl(210 40% 96.1%); + --color-muted-foreground: hsl(215.4 16.3% 46.9%); + + /* Accent */ + --color-accent: hsl(210 40% 96.1%); + --color-accent-foreground: hsl(222.2 47.4% 11.2%); + + /* Destructive */ + --color-destructive: hsl(0 84.2% 60.2%); + --color-destructive-foreground: hsl(210 40% 98%); + + /* Border, input, ring */ + --color-border: hsl(214.3 31.8% 91.4%); + --color-input: hsl(214.3 31.8% 91.4%); + --color-ring: hsl(222.2 84% 4.9%); + + /* Sidebar */ + --color-sidebar: hsl(0 0% 98%); + --color-sidebar-foreground: hsl(240 5.3% 26.1%); + --color-sidebar-primary: hsl(240 5.9% 10%); + --color-sidebar-primary-foreground: hsl(0 0% 98%); + --color-sidebar-accent: hsl(240 4.8% 95.9%); + --color-sidebar-accent-foreground: hsl(240 5.9% 10%); + --color-sidebar-border: hsl(220 13% 91%); + --color-sidebar-ring: hsl(217.2 91.2% 59.8%); + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + + /* Fonts */ + --font-heading: "Space Grotesk", sans-serif; + --font-body: "Inter", sans-serif; +} + +/* Dark mode */ +.dark { + --color-background: hsl(222.2 84% 4.9%); + --color-foreground: hsl(210 40% 98%); + --color-card: hsl(222.2 84% 4.9%); + --color-card-foreground: hsl(210 40% 98%); + --color-popover: hsl(222.2 84% 4.9%); + --color-popover-foreground: hsl(210 40% 98%); + --color-primary: hsl(210 40% 98%); + --color-primary-foreground: hsl(222.2 47.4% 11.2%); + --color-secondary: hsl(217.2 32.6% 17.5%); + --color-secondary-foreground: hsl(210 40% 98%); + --color-muted: hsl(217.2 32.6% 17.5%); + --color-muted-foreground: hsl(215 20.2% 65.1%); + --color-accent: hsl(217.2 32.6% 17.5%); + --color-accent-foreground: hsl(210 40% 98%); + --color-destructive: hsl(0 62.8% 30.6%); + --color-destructive-foreground: hsl(210 40% 98%); + --color-border: hsl(217.2 32.6% 17.5%); + --color-input: hsl(217.2 32.6% 17.5%); + --color-ring: hsl(212.7 26.8% 83.9%); + --color-sidebar: hsl(240 5.9% 10%); + --color-sidebar-foreground: hsl(240 4.8% 95.9%); + --color-sidebar-primary: hsl(224.3 76.3% 48%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-accent: hsl(240 3.7% 15.9%); + --color-sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --color-sidebar-border: hsl(240 3.7% 15.9%); + --color-sidebar-ring: hsl(217.2 91.2% 59.8%); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..74c50cb --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import './globals.css'; +import { Providers } from './providers'; +import ShopifyCart from '@/components/shopify/cart-drawer'; +import ShopHeader from '@/components/shopify/shop-header'; +import ShopFooter from '@/components/shopify/shop-footer'; +import Theme from '@/components/Theme'; + +export const metadata: Metadata = { + title: 'Shop | Frontend', + description: 'Browse our products', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + +
+ {children} +
+ + +
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..3692439 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from 'react'; +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+
+
+ 404 +
+
+
+ This page could not be found. +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..5b7a0b4 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const Home: React.FC = () => { + return ( +
+

Start Prompting

+
+ ); +}; + +export default Home; \ No newline at end of file diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx new file mode 100644 index 0000000..a9ce14f --- /dev/null +++ b/app/products/[handle]/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { useProduct, type Product } from '@/hooks/use-shopify-products'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import ProductDetailGallery from '@/components/shopify/product-detail/product-detail-gallery'; +import ProductDetailInfo from '@/components/shopify/product-detail/product-detail-info'; +import ProductRecommendations from '@/components/shopify/product-detail/product-recommendations'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +interface ProductVariant { + id: string; + title: string; + price: { + amount: string; + currencyCode: string; + }; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: { + url: string; + altText?: string; + }; +} + +export default function ProductDetailPage() { + const params = useParams(); + const handle = params.handle as string; + const { addItem, openCart } = useShopifyCart(); + + const { product, loading, error } = useProduct(handle); + + const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>({}); + const [quantity, setQuantity] = useState(1); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [addingToCart, setAddingToCart] = useState(false); + + // Initialize variant when product loads + useEffect(() => { + if (product) { + const firstVariant = product.variants.edges[0]?.node; + if (firstVariant) { + setSelectedVariant(firstVariant); + + const initialOptions: Record = {}; + firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => { + initialOptions[option.name] = option.value; + }); + setSelectedOptions(initialOptions); + } + } + }, [product]); + + const handleOptionChange = (optionName: string, value: string) => { + const newOptions = { ...selectedOptions, [optionName]: value }; + setSelectedOptions(newOptions); + + // Find matching variant + const matchingVariant = product?.variants.edges.find(({ node }) => { + return node.selectedOptions.every(option => + newOptions[option.name] === option.value + ); + }); + + if (matchingVariant) { + setSelectedVariant(matchingVariant.node); + + // Update image if variant has an associated image + if (matchingVariant.node.image && product) { + const variantImageUrl = matchingVariant.node.image.url; + const imageIndex = product.images.edges.findIndex( + edge => edge.node.url === variantImageUrl + ); + if (imageIndex !== -1) { + setSelectedImageIndex(imageIndex); + } + } + } + }; + + const handleAddToCart = async () => { + if (!selectedVariant || !product) return; + + try { + setAddingToCart(true); + await addItem(selectedVariant.id, quantity); + openCart(); + } catch (err) { + console.error('Failed to add item to cart:', err); + } finally { + setAddingToCart(false); + } + }; + + if (loading) { + return ( +
+
+ {/* Image Gallery Skeleton */} +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Product Info Skeleton */} +
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !product) { + return ( +
+ + + + Product Not Found + + + {error || 'The requested product could not be found.'} + + + +
+ ); + } + + return ( +
+
+ + + + + Home + + + + + {product.title} + + + +
+ edge.node)} + selectedImageIndex={selectedImageIndex} + onImageSelect={setSelectedImageIndex} + /> + +
+
+ + +
+ ); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..94abfec --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; +import { ShopifyProvider } from '@/contexts/shopify-context'; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/components/About.tsx b/components/About.tsx new file mode 100644 index 0000000..c42f9eb --- /dev/null +++ b/components/About.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +const About: React.FC = () => { + return ( +
+

+ About Page +

+
+ ); +}; + +export default About; \ No newline at end of file diff --git a/components/Theme.tsx b/components/Theme.tsx new file mode 100644 index 0000000..1e5c73f --- /dev/null +++ b/components/Theme.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import config from '../lib/config.json'; + +const Theme: React.FC = () => { + const headerFont = config.brand.fonts.header; + const bodyFont = config.brand.fonts.body; + const primaryColor = config.brand.colors.primary; + const secondaryColor = config.brand.colors.secondary; + + return ( + <> + {/* Font Imports */} + + + + {/* Tailwind Browser Script */} + + + {/* Theme Styles */} + + + ); +}; + +export default Theme; diff --git a/components/magicui/aurora-text.tsx b/components/magicui/aurora-text.tsx new file mode 100644 index 0000000..8b49e42 --- /dev/null +++ b/components/magicui/aurora-text.tsx @@ -0,0 +1,40 @@ +import React, { memo } from 'react'; +import { cn } from '@/lib/utils'; + +interface AuroraTextProps { + children: React.ReactNode; + className?: string; + colors?: string[]; + speed?: number; +} + +export const AuroraText = memo( + ({ + children, + className, + colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'], + speed = 1, + }: AuroraTextProps) => { + const animationDuration = `${10 / speed}s`; + + const gradientStyle = { + backgroundImage: `linear-gradient(90deg, ${colors.join(', ')}, ${colors[0]})`, + backgroundSize: '200% 100%', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + animation: `aurora-flow ${animationDuration} ease-in-out infinite`, + } as React.CSSProperties; + + return ( + + {children} + + + ); + } +); + +AuroraText.displayName = 'AuroraText'; diff --git a/components/magicui/blur-fade.tsx b/components/magicui/blur-fade.tsx new file mode 100644 index 0000000..682c2b3 --- /dev/null +++ b/components/magicui/blur-fade.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useRef, useEffect, useState } from 'react'; +import { cn } from '@/lib/utils'; + +interface BlurFadeProps { + children: React.ReactNode; + className?: string; + duration?: number; + delay?: number; + inView?: boolean; +} + +export function BlurFade({ + children, + className, + duration = 0.4, + delay = 0, + inView = false, +}: BlurFadeProps) { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(!inView); + + useEffect(() => { + if (!inView) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(entry.target); + } + }, + { threshold: 0.1, rootMargin: '-50px' } + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => { + if (ref.current) { + observer.unobserve(ref.current); + } + }; + }, [inView]); + + return ( +
+ {children} +
+ ); +} diff --git a/components/shopify/cart-drawer.tsx b/components/shopify/cart-drawer.tsx new file mode 100644 index 0000000..700d8ec --- /dev/null +++ b/components/shopify/cart-drawer.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React from 'react'; +import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + AnimatePresence, +} from '@/components/ui/sheet'; + +const CartDrawer: React.FC = () => { + const { isOpen, closeCart, items, itemCount, totalAmount, checkoutUrl, loading, removeItem, updateItemQuantity } = useShopifyCart(); + + const handleCheckout = () => { + if (checkoutUrl) { + redirectToCheckout(checkoutUrl); + } + }; + + const getItemImage = (item: typeof items[0]) => { + return item.merchandise.image?.url; + }; + + const getSelectedOptions = (item: typeof items[0]) => { + return item.merchandise.selectedOptions ?? []; + }; + + return ( + !open && closeCart()} side="right"> + + {isOpen && ( + + {/* Header */} + +
+ + Shopping Cart ({itemCount}) + + +
+
+ + {/* Cart Items */} + + {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

Your cart is empty

+

Add some products to get started!

+ +
+ ) : ( +
+ {items.map((item) => { + const image = getItemImage(item); + const selectedOptions = getSelectedOptions(item); + + return ( +
+ {/* Product Image */} +
+ {image ? ( + {item.merchandise.product.title} + ) : ( +
+ +
+ )} +
+ + {/* Product Details */} +
+

+ {item.merchandise.product.title} +

+ + {/* Variant Info */} + {selectedOptions.length > 0 && ( +
+ {selectedOptions.map((option, index) => ( + + {option.value} + {index < selectedOptions.length - 1 ? ' / ' : ''} + + ))} +
+ )} + + {/* Quantity Controls */} +
+
+ + + {item.quantity} + + +
+
+
+ + {/* Price */} +
+ + ${parseFloat(item.merchandise.price.amount).toFixed(2)} + +
+ + {/* Remove Button */} +
+ +
+
+ ); + })} +
+ )} +
+ + {/* Footer - Checkout Section */} + {items.length > 0 && ( +
+ {/* Subtotal */} +
+ Subtotal + + ${totalAmount.toFixed(2)} + +
+ +
+ Shipping and taxes calculated at checkout +
+ + {/* Action Buttons */} +
+ + + +
+
+ )} +
+ )} +
+
+ ); +}; + +export default CartDrawer; diff --git a/components/shopify/collection-card.tsx b/components/shopify/collection-card.tsx new file mode 100644 index 0000000..99a6c7f --- /dev/null +++ b/components/shopify/collection-card.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; + +interface CollectionImage { + url: string; + altText?: string; +} + +interface Collection { + id: string; + title: string; + handle: string; + description?: string; + image?: CollectionImage; +} + +interface CollectionCardProps { + collection: Collection; +} + +const CollectionCard: React.FC = ({ collection }) => { + return ( + + + {/* Collection Image */} +
+ {collection.image ? ( + {collection.image.altText + ) : ( +
+ +
+ )} +
+ + {/* Collection Info */} + +

+ {collection.title} +

+ + {collection.description && ( +

+ {collection.description.substring(0, 100)} + {collection.description.length > 100 ? '...' : ''} +

+ )} + +
+ View Collection + +
+
+
+ + ); +}; + +export default CollectionCard; \ No newline at end of file diff --git a/components/shopify/collection-detail.tsx b/components/shopify/collection-detail.tsx new file mode 100644 index 0000000..a81e9aa --- /dev/null +++ b/components/shopify/collection-detail.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import { useCollectionProducts } from '@/hooks/use-shopify-collections'; +import ProductCard from './product-card'; + +const CollectionDetail: React.FC = () => { + const params = useParams(); + const handle = params?.handle as string; + + const { collection, loading, error, refetch } = useCollectionProducts(handle); + + // Format title from handle + const formattedTitle = handle + ? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + : 'Collection'; + + if (loading) { + return ( +
+
+

+ {formattedTitle} +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ {formattedTitle} +

+ +
+ +

+ Failed to Load Collection +

+

+ {error} +

+ +
+
+
+ ); + } + + const products = collection?.products || []; + const title = collection?.title || formattedTitle; + + return ( +
+
+

+ {title} +

+ + {products.length === 0 ? ( +
+
+ +

+ No Products in Collection +

+

+ This collection doesn't have any products yet. +

+
+
+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default CollectionDetail; diff --git a/components/shopify/collections.tsx b/components/shopify/collections.tsx new file mode 100644 index 0000000..232724d --- /dev/null +++ b/components/shopify/collections.tsx @@ -0,0 +1,104 @@ +'use client'; + +import React from 'react'; +import { useCollections } from '@/hooks/use-shopify-collections'; +import CollectionCard from './collection-card'; + +const Collections: React.FC = () => { + const { collections, loading, error, refetch } = useCollections(20); + + if (loading) { + return ( +
+
+

+ Our Collections +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ Our Collections +

+ +
+ +

+ Failed to Load Collections +

+

+ {error} +

+ +
+
+
+ ); + } + + if (collections.length === 0) { + return ( +
+
+

+ Our Collections +

+ +
+ +

+ No Collections Found +

+

+ Check back later or configure your Shopify store connection. +

+
+
+
+ ); + } + + return ( +
+
+

+ Our Collections +

+ + {/* Collections Grid */} +
+ {collections.map((collection) => ( + + ))} +
+
+
+ ); +}; + +export default Collections; diff --git a/components/shopify/product-card.tsx b/components/shopify/product-card.tsx new file mode 100644 index 0000000..01eafdf --- /dev/null +++ b/components/shopify/product-card.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import Link from 'next/link'; +import { useShopifyCart, addCartLines } from '@/hooks/use-shopify-cart'; +import { truncate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; +} + +interface Product { + id: string; + title: string; + description?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; +} + +interface ProductCardProps { + product: Product; + onAddToCart?: (product: Product) => void; +} + +const ProductCard: React.FC = ({ product, onAddToCart }) => { + const { cartId, openCart } = useShopifyCart(); + + const firstImage = product.images.edges[0]?.node; + const price = product.priceRange.minVariantPrice; + const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; + const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount); + const firstVariant = product.variants.edges[0]?.node; + const isAvailable = firstVariant?.availableForSale || false; + + const formatPrice = (price: ProductPrice) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: price.currencyCode, + }).format(parseFloat(price.amount)); + }; + + const handleAddToCart = async (e: React.MouseEvent) => { + e.preventDefault(); // Prevent navigation when clicking add to cart + e.stopPropagation(); + + if (!firstVariant || !isAvailable || !cartId) return; + + try { + await addCartLines(cartId, [{ merchandiseId: firstVariant.id, quantity: 1 }]); + openCart(); + + if (onAddToCart) { + onAddToCart(product); + } + } catch (err) { + console.error('Failed to add item to cart:', err); + } + }; + + return ( + + {/* Product Image */} +
+ {firstImage ? ( + + {firstImage.altText + + ) : ( +
+ +
+ )} + + {/* Discount Badge */} + {hasDiscount && compareAtPrice && ( + + {Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF + + )} +
+ + {/* Product Info */} + +

+ {truncate(product.title, 50)} +

+ + {/* Price Section */} +
+ + ${parseFloat(price.amount).toFixed(2)} + +
+ + {/* View Details Button */} + + + +
+
+ ); +}; + +export default ProductCard; \ No newline at end of file diff --git a/components/shopify/product-detail.tsx b/components/shopify/product-detail.tsx new file mode 100644 index 0000000..5f51fed --- /dev/null +++ b/components/shopify/product-detail.tsx @@ -0,0 +1,3 @@ +import ProductDetail from './product-detail/index.tsx'; + +export default ProductDetail; \ No newline at end of file diff --git a/components/shopify/product-detail/index.tsx b/components/shopify/product-detail/index.tsx new file mode 100644 index 0000000..4813a99 --- /dev/null +++ b/components/shopify/product-detail/index.tsx @@ -0,0 +1,211 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { useProduct, type Product } from '@/hooks/use-shopify-products'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import ProductDetailGallery from './product-detail-gallery'; +import ProductDetailInfo from './product-detail-info'; +import ProductRecommendations from './product-recommendations'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +interface ProductVariant { + id: string; + title: string; + price: { + amount: string; + currencyCode: string; + }; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: { + url: string; + altText?: string; + }; +} + +export type { Product }; + +interface ProductDetailProps { + handle?: string; +} + +const ProductDetail: React.FC = ({ handle: handleProp }) => { + const params = useParams(); + const handle = handleProp || (params?.handle as string); + const { addItem, openCart } = useShopifyCart(); + + const { product, loading, error } = useProduct(handle); + + const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>({}); + const [quantity, setQuantity] = useState(1); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [addingToCart, setAddingToCart] = useState(false); + + // Initialize variant when product loads + useEffect(() => { + if (product) { + const firstVariant = product.variants.edges[0]?.node; + if (firstVariant) { + setSelectedVariant(firstVariant); + + const initialOptions: Record = {}; + firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => { + initialOptions[option.name] = option.value; + }); + setSelectedOptions(initialOptions); + } + } + }, [product]); + + const handleOptionChange = (optionName: string, value: string) => { + const newOptions = { ...selectedOptions, [optionName]: value }; + setSelectedOptions(newOptions); + + // Find matching variant + const matchingVariant = product?.variants.edges.find(({ node }) => { + return node.selectedOptions.every(option => + newOptions[option.name] === option.value + ); + }); + + if (matchingVariant) { + setSelectedVariant(matchingVariant.node); + + // Update image if variant has an associated image + if (matchingVariant.node.image && product) { + const variantImageUrl = matchingVariant.node.image.url; + const imageIndex = product.images.edges.findIndex( + edge => edge.node.url === variantImageUrl + ); + if (imageIndex !== -1) { + setSelectedImageIndex(imageIndex); + } + } + } + }; + + const handleAddToCart = async () => { + if (!selectedVariant || !product) return; + + try { + setAddingToCart(true); + await addItem(selectedVariant.id, quantity); + openCart(); + } catch (err) { + console.error('Failed to add item to cart:', err); + } finally { + setAddingToCart(false); + } + }; + + if (loading) { + return ( +
+
+ {/* Image Gallery Skeleton */} +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Product Info Skeleton */} +
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !product) { + return ( +
+ + + + Product Not Found + + + {error || 'The requested product could not be found.'} + + + +
+ ); + } + + return ( +
+
+ + + + + Home + + + + + + Shop + + + + + {product.title} + + + +
+ edge.node)} + selectedImageIndex={selectedImageIndex} + onImageSelect={setSelectedImageIndex} + /> + +
+
+ + +
+ ); +}; + +export default ProductDetail; diff --git a/components/shopify/product-detail/product-detail-gallery.tsx b/components/shopify/product-detail/product-detail-gallery.tsx new file mode 100644 index 0000000..cd444c4 --- /dev/null +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductDetailGalleryProps { + images: ProductImage[]; + selectedImageIndex?: number; + onImageSelect?: (index: number) => void; +} + +const ProductDetailGallery: React.FC = ({ + images, + selectedImageIndex = 0, + onImageSelect +}) => { + const selectedImage = selectedImageIndex; + const setSelectedImage = onImageSelect || (() => {}); + + return ( +
+ {/* Main Image */} +
+ {images.length > 0 ? ( + {images[selectedImage].altText + ) : ( +
+ +
+ )} +
+ + {/* Image Thumbnails */} + {images.length > 1 && ( +
+ {images.map((image, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProductDetailGallery; \ No newline at end of file diff --git a/components/shopify/product-detail/product-detail-info.tsx b/components/shopify/product-detail/product-detail-info.tsx new file mode 100644 index 0000000..1d6ab87 --- /dev/null +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { Product, ProductVariant } from './index.tsx'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Spinner } from '@/components/ui/spinner'; + +interface ProductDetailInfoProps { + product: Product; + selectedVariant: ProductVariant | null; + selectedOptions: Record; + quantity: number; + setQuantity: (quantity: number) => void; + handleAddToCart: () => void; + onOptionChange: (optionName: string, value: string) => void; + loading?: boolean; +} + +const ProductDetailInfo: React.FC = ({ + product, + selectedVariant, + selectedOptions, + quantity, + setQuantity, + handleAddToCart, + onOptionChange, + loading = false, +}) => { + const formatPrice = (price: { amount: string; currencyCode: string }) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(parseFloat(price.amount)); + }; + + const price = selectedVariant?.price || product.priceRange.minVariantPrice; + const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; + const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount); + + return ( +
+

+ {product.title} +

+ + {/* Price */} +
+ + {formatPrice(price)} + + {hasDiscount && compareAtPrice && ( + <> + + {formatPrice(compareAtPrice)} + + + {Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF + + + )} +
+ + {/* Description */} + {product.description && ( +
+ {product.descriptionHtml ? ( +
+ ) : ( +

{product.description}

+ )} +
+ )} + + {/* Product Options */} + {product.options.map(option => ( +
+ +
+ {option.values.map(value => ( + + ))} +
+
+ ))} + + {/* Quantity Selector */} +
+ +
+ + {quantity} + +
+
+ + {/* Add to Cart Button */} + + + {/* Additional Info */} +
+
+
+ + Free shipping on orders over $100 +
+
+ + 30-day return policy +
+
+ + Secure payment +
+
+
+
+ ); +}; + +export default ProductDetailInfo; \ No newline at end of file diff --git a/components/shopify/product-detail/product-recommendations.tsx b/components/shopify/product-detail/product-recommendations.tsx new file mode 100644 index 0000000..08833f3 --- /dev/null +++ b/components/shopify/product-detail/product-recommendations.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import { useProductRecommendations } from '@/hooks/use-shopify-products'; +import ProductCard from '../product-card'; + +interface ProductRecommendationsProps { + productId: string; +} + +const ProductRecommendations: React.FC = ({ productId }) => { + const { recommendations, loading, error } = useProductRecommendations(productId); + + // Don't show section if we're not loading and have no recommendations + if (!loading && (!recommendations || recommendations.length === 0)) { + return null; + } + + return ( +
+
+

+ You Might Also Like +

+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +
+

Recommendations could not be loaded

+
+ ) : ( +
+ {recommendations.slice(0, 4).map((recommendedProduct) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default ProductRecommendations; diff --git a/components/shopify/products.tsx b/components/shopify/products.tsx new file mode 100644 index 0000000..034c592 --- /dev/null +++ b/components/shopify/products.tsx @@ -0,0 +1,239 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import ProductCard from './product-card'; +import { getProducts } from '@/hooks/use-shopify-products'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; +} + +interface Product { + id: string; + title: string; + description?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; +} + +interface ProductsProps { + title?: string; + limit?: number; + showLoadMore?: boolean; +} + +const Products: React.FC = ({ + title = "Our Products", + limit = 12, + showLoadMore = true +}) => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMoreProducts, setHasMoreProducts] = useState(true); + + const fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => { + try { + if (loadMore) { + setLoadingMore(true); + } else { + setLoading(true); + setError(null); + } + + const newProducts = await getProducts({ + first: limit, + sortKey: 'CREATED_AT', + reverse: true + }); + + if (loadMore) { + // Filter out products that already exist + const existingIds = new Set(currentProducts.map(p => p.id)); + const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id)); + + if (uniqueNewProducts.length === 0) { + setHasMoreProducts(false); + } else { + setProducts(prev => [...prev, ...uniqueNewProducts]); + } + } else { + setProducts(newProducts); + setHasMoreProducts(newProducts.length === limit); + } + } catch (err) { + console.error('Error fetching products:', err); + setError(err instanceof Error ? err.message : 'Failed to load products'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchProducts(); + }, [limit]); + + const handleAddToCart = async (product: Product) => { + // Here you would typically integrate with cart functionality + console.log('Adding to cart:', product); + }; + + const handleLoadMore = () => { + if (!loadingMore && hasMoreProducts) { + fetchProducts(products, true); + } + }; + + if (loading) { + return ( +
+
+

+ {title} +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ {title} +

+ +
+ +

+ Failed to Load Products +

+

+ {error} +

+ +
+
+
+ ); + } + + if (products.length === 0) { + return ( +
+
+

+ {title} +

+ +
+ +

+ No Products Found +

+

+ Check back later or configure your Shopify store connection. +

+
+
+
+ ); + } + + return ( +
+
+

+ {title} +

+ + {/* Products Grid */} +
+ {products.map((product) => ( + + ))} +
+ + {/* Load More Button */} + {showLoadMore && hasMoreProducts && ( +
+ +
+ )} +
+
+ ); +}; + +export default Products; \ No newline at end of file diff --git a/components/shopify/shop-footer.tsx b/components/shopify/shop-footer.tsx new file mode 100644 index 0000000..452ff07 --- /dev/null +++ b/components/shopify/shop-footer.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const Footer: React.FC = () => { + return ( +
+
+

+ Store +

+

+ Your premium shopping destination +

+
+

© 2025 Store. All rights reserved.

+
+
+
+ ); +}; + +export default Footer; diff --git a/components/shopify/shop-header.tsx b/components/shopify/shop-header.tsx new file mode 100644 index 0000000..fb3b2e4 --- /dev/null +++ b/components/shopify/shop-header.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import config from '@/lib/config.json'; + +const CartIcon: React.FC = () => { + const { toggleCart, itemCount } = useShopifyCart(); + + return ( + + ); +}; + +const Header: React.FC = () => { + return ( + + ); +}; + +export default Header; diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..9d3232c --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,197 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { cn } from '@/lib/utils'; + +interface AccordionContextType { + value: string | string[]; + onValueChange: (value: string) => void; + type: 'single' | 'multiple'; +} + +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; +} + +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] + ); + + 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); + + 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); + + return ( +
+
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..3c5a3e4 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,60 @@ +import * as React from "react" + +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", +} + +function Alert({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & { variant?: AlertVariant }) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..6cd2e95 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface AvatarProps extends React.ComponentProps<'div'> { + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +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); + + 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; + } + + return ( + + ); +} + +interface AvatarFallbackProps extends React.ComponentProps<'div'> { + children: React.ReactNode; +} + +function AvatarFallback({ + className, + children, + ...props +}: AvatarFallbackProps) { + return ( +
+ {children} +
+ ); +} + +export { Avatar, AvatarImage, AvatarFallback }; +export type { AvatarProps }; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..ef46f46 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +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', +}; + +function Badge({ + className, + 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, + }); + } + + return ( + + {children} + + ); +} + +export { Badge, badgeVariants }; +export type { BadgeProps }; diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..cbe7189 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return