commit b5a79b647530ae20d7996082cc202c79c2b36be6 Author: Rami Bitar Date: Sun Apr 19 11:17:41 2026 -0400 Initial commit diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..928fbad --- /dev/null +++ b/.env.local @@ -0,0 +1,3 @@ +NEXT_PUBLIC_SHOPIFY_DOMAIN=frontend-ai.myshopify.com +NEXT_PUBLIC_API_VERSION=2025-10 +NEXT_PUBLIC_PRODUCT=women-t-shirt \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16f96a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/* +build/* +dist/* +.DS_Store +.vscode/* +.next \ No newline at end of file diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..86f34a6 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,23 @@ +"use client"; + +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..e99bfe1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,93 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@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..05d8255 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from 'next'; +import { Toaster } from 'sonner'; +import { ShopifyProvider } from '@/contexts/shopify-context'; +import CartDrawer from '@/components/CartDrawer'; +import Theme from '@/components/Theme'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Frontend', + description: 'Start prompting', +}; + +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..5a5ae5d --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,17 @@ +"use client"; + +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..54f0b21 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,18 @@ +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import ProductDetail from '@/components/product-detail/ProductDetail'; +import config from '@/lib/config.json'; + +export default function Page() { + const productHandle = config.data.product; + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/CartDrawer.tsx b/components/CartDrawer.tsx new file mode 100644 index 0000000..ec4c512 --- /dev/null +++ b/components/CartDrawer.tsx @@ -0,0 +1,206 @@ +"use client"; + +import React, { useState } from 'react'; +import { RiCloseLine, RiImageLine, RiSubtractLine, RiAddLine, RiLoader4Line } from '@remixicon/react'; +import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + SheetFooter, + AnimatePresence, +} from './ui/sheet'; +import { Button } from './ui/button'; + +const CartDrawer: React.FC = () => { + const { + isOpen, + items, + itemCount, + totalAmount, + checkoutUrl, + removeItem, + updateItemQuantity, + closeCart + } = useShopifyCart(); + const [isCheckingOut, setIsCheckingOut] = useState(false); + + const handleCheckout = async () => { + if (!checkoutUrl) return; + + setIsCheckingOut(true); + try { + redirectToCheckout(checkoutUrl); + } catch (error) { + console.error('Error during checkout:', error); + alert('Failed to proceed to checkout. Please try again.'); + setIsCheckingOut(false); + } + }; + + return ( + !open && closeCart()}> + + {isOpen && ( + + {/* Header */} + +
+ + Shopping Cart ({itemCount}) + + +
+
+ + {/* Cart Items */} + + {items.length === 0 ? ( +
+

Your cart is empty

+

Add some products to get started!

+ +
+ ) : ( +
+ {items.map((item) => ( +
+ {/* Product Image */} +
+ {item.merchandise.image?.url ? ( + {item.merchandise.image.altText + ) : ( +
+ +
+ )} +
+ + {/* Product Details */} +
+

+ {item.merchandise.product.title} +

+ + {/* Variant Info */} + {item.merchandise.selectedOptions && item.merchandise.selectedOptions.length > 0 && ( +
+ {item.merchandise.selectedOptions.map((option, index) => ( + + {option.value} + {index < item.merchandise.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/CollectionCard.tsx b/components/CollectionCard.tsx new file mode 100644 index 0000000..591a9c7 --- /dev/null +++ b/components/CollectionCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Link from 'next/link'; + +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/CollectionDetail.tsx b/components/CollectionDetail.tsx new file mode 100644 index 0000000..9ef10cf --- /dev/null +++ b/components/CollectionDetail.tsx @@ -0,0 +1,215 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { getCollectionProducts } from '@/hooks/use-shopify-collections'; +import ProductCard from './ProductCard'; +import ProductModal from './ProductModal'; +import { Button } from './ui/button'; + +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 Collection { + id: string; + title: string; + handle: string; + description?: string; + image?: ProductImage; +} + +interface CollectionDetailProps { + collectionHandle?: string; +} + +const CollectionDetail: React.FC = ({ collectionHandle }) => { + const [collection, setCollection] = useState(null); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + if (!collectionHandle) return; + + const fetchCollectionProducts = async () => { + try { + setLoading(true); + setError(null); + + const data = await getCollectionProducts({ + collection: collectionHandle, + limit: 20, + sortKey: 'COLLECTION_DEFAULT', + reverse: false + }); + + setCollection(data.collection); + setProducts(data.products); + } catch (err) { + console.error('Error fetching collection products:', err); + setError(err instanceof Error ? err.message : 'Failed to load collection products'); + } finally { + setLoading(false); + } + }; + + fetchCollectionProducts(); + }, [collectionHandle]); + + if (loading) { + return ( +
+
+

+ Collection +

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

+ Collection Not Found +

+

+ {error || "We couldn't find the collection you're looking for. It may have been removed or the link might be incorrect."} +

+ +
+
+ ); + } + + return ( +
+ {/* Hero Banner */} + {collection?.image && ( +
+ {collection.image.altText +
+

+ {collection.title} +

+
+
+ )} + + {/* Products Section */} +
+
+ {products.length === 0 ? ( +
+
+ +

+ No Products in Collection +

+

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

+
+
+ ) : ( +
+ {products.map((product) => ( + { + setSelectedProduct(product); + setIsModalOpen(true); + }} + /> + ))} +
+ )} +
+
+ + {/* Product Modal */} + {selectedProduct && ( + { + setIsModalOpen(false); + setSelectedProduct(null); + }} + /> + )} +
+ ); +}; + +export default CollectionDetail; \ No newline at end of file diff --git a/components/Collections.tsx b/components/Collections.tsx new file mode 100644 index 0000000..157216a --- /dev/null +++ b/components/Collections.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { getCollections } from '@/hooks/use-shopify-collections'; +import CollectionCard from './CollectionCard'; +import { Button } from './ui/button'; + +interface CollectionImage { + url: string; + altText?: string; +} + +interface Collection { + id: string; + title: string; + handle: string; + description?: string; + image?: CollectionImage; +} + +const Collections: React.FC = () => { + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchCollections = async () => { + try { + setLoading(true); + setError(null); + const collectionData = await getCollections(20); + setCollections(collectionData); + } catch (err) { + console.error('Error fetching collections:', err); + setError(err instanceof Error ? err.message : 'Failed to load collections'); + } finally { + setLoading(false); + } + }; + + fetchCollections(); + }, []); + + 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; \ No newline at end of file diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..4b8c70b --- /dev/null +++ b/components/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/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..fe4a795 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React from 'react'; +import Link from 'next/link'; +import { RiShoppingBagLine } from '@remixicon/react'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import config from '../lib/config.json'; + +const CartIcon: React.FC = () => { + const { itemCount, toggleCart } = useShopifyCart(); + + return ( + + ); +}; + +const Header: React.FC = () => { + return ( + + ); +}; + +export default Header; diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx new file mode 100644 index 0000000..eb7db0a --- /dev/null +++ b/components/ProductCard.tsx @@ -0,0 +1,148 @@ +"use client"; + +import React from 'react'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import { truncate } from '../lib/utils'; +import { Card, CardContent } from './ui/card'; +import { Button } from './ui/button'; + +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; + onClick?: (product: Product) => void; +} + +const ProductCard: React.FC = ({ product, onAddToCart, onClick }) => { + const { addItem, 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 handleAddToCart = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!firstVariant || !isAvailable) return; + + try { + await addItem(firstVariant.id, 1); + openCart(); + + if (onAddToCart) { + onAddToCart(product); + } + } catch (error) { + console.error('Failed to add item to cart:', error); + } + }; + + return ( + + {/* Product Image */} +
{ + if (onClick) { + onClick(product); + } + }} + > + {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, 60)} +

+ + {/* Price Section */} +
+
+ + ${parseFloat(price.amount).toFixed(2)} + +
+
+ + {/* View Details Button */} + +
+
+ ); +}; + +export default ProductCard; diff --git a/components/ProductModal.tsx b/components/ProductModal.tsx new file mode 100644 index 0000000..b3e9e0d --- /dev/null +++ b/components/ProductModal.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React, { useEffect } from 'react'; +import ProductDetail from './product-detail/ProductDetail'; +import { Button } from './ui/button'; + +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 ProductModalProps { + product: Product; + isOpen: boolean; + onClose: () => void; +} + +const ProductModal: React.FC = ({ product, isOpen, onClose }) => { + + // Close modal on ESC key press + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Close Button */} + + + {/* Product Detail Component */} + +
+
+ ); +}; + +export default ProductModal; diff --git a/components/Products.tsx b/components/Products.tsx new file mode 100644 index 0000000..e7e0549 --- /dev/null +++ b/components/Products.tsx @@ -0,0 +1,191 @@ +"use client"; + +import React from 'react'; +import ProductCard from './ProductCard'; +import { useProducts } from '@/hooks/use-shopify-products'; +import { Button } from './ui/button'; + +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; + onViewDetails?: (product: Product) => void; +} + +const Products: React.FC = ({ + title = "Our Products", + limit = 12, + showLoadMore = true, + onViewDetails +}) => { + const { products, loading, error, refetch } = useProducts({ + first: limit, + sortKey: 'CREATED_AT', + reverse: true + }); + + const handleAddToCart = async (product: Product) => { + console.log('Adding to cart:', product); + }; + + 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 && products.length >= limit && ( +
+ +
+ )} +
+
+ ); +}; + +export default Products; 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/product-detail/ProductDetail.tsx b/components/product-detail/ProductDetail.tsx new file mode 100644 index 0000000..f04958b --- /dev/null +++ b/components/product-detail/ProductDetail.tsx @@ -0,0 +1,240 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { getProduct } from '@/hooks/use-shopify-products'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import ProductDetailGallery from './ProductDetailGallery'; +import ProductDetailInfo from './ProductDetailInfo'; +import { Button } from '../ui/button'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +export interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: { + url: string; + altText?: string; + }; +} + +interface ProductOption { + id: string; + name: string; + values: string[]; +} + +export interface Product { + id: string; + title: string; + description?: string; + descriptionHtml?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; + options: ProductOption[]; +} + +type ProductDetailProps = { + handle: string; + onAddToCart?: () => void; +} + +const ProductDetail: React.FC = ({ handle, onAddToCart: onAddToCartCallback }) => { + const { addItem, openCart } = useShopifyCart(); + + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>({}); + const [quantity, setQuantity] = useState(1); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [isAddingToCart, setIsAddingToCart] = useState(false); + + useEffect(() => { + if (!handle) return; + + const fetchProduct = async () => { + try { + setLoading(true); + setError(null); + const productData = await getProduct(handle); + + if (!productData) { + setError('Product not found'); + return; + } + + setProduct(productData); + + // Set default variant + const firstVariant = productData.variants.edges[0]?.node; + if (firstVariant) { + setSelectedVariant(firstVariant); + + // Initialize selected options + const initialOptions: Record = {}; + firstVariant.selectedOptions.forEach(option => { + initialOptions[option.name] = option.value; + }); + setSelectedOptions(initialOptions); + } + } catch (err) { + console.error('Error fetching product:', err); + setError(err instanceof Error ? err.message : 'Failed to load product'); + } finally { + setLoading(false); + } + }; + + fetchProduct(); + }, [handle]); + + 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 imageIndex = product.images.edges.findIndex( + ({ node }) => node.url === matchingVariant.node.image?.url + ); + if (imageIndex !== -1) { + setSelectedImageIndex(imageIndex); + } + } + } + }; + + const handleAddToCart = async () => { + if (!selectedVariant || !product) return; + + setIsAddingToCart(true); + try { + await addItem(selectedVariant.id, quantity); + openCart(); + + if (onAddToCartCallback) { + onAddToCartCallback(); + } + } catch (error) { + console.error('Failed to add item to cart:', error); + } finally { + setIsAddingToCart(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 ( +
+
+
+ edge.node)} + selectedImageIndex={selectedImageIndex} + onImageChange={setSelectedImageIndex} + /> + +
+
+
+ ); +}; + +export default ProductDetail; diff --git a/components/product-detail/ProductDetailGallery.tsx b/components/product-detail/ProductDetailGallery.tsx new file mode 100644 index 0000000..578aa34 --- /dev/null +++ b/components/product-detail/ProductDetailGallery.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductDetailGalleryProps { + images: ProductImage[]; + selectedImageIndex: number; + onImageChange: (index: number) => void; +} + +const ProductDetailGallery: React.FC = ({ images, selectedImageIndex, onImageChange }) => { + + return ( +
+ {/* Main Image */} +
+ {images.length > 0 ? ( + {images[selectedImageIndex].altText + ) : ( +
+ +
+ )} +
+ + {/* Image Thumbnails */} + {images.length > 1 && ( +
+ {images.map((image, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProductDetailGallery; \ No newline at end of file diff --git a/components/product-detail/ProductDetailInfo.tsx b/components/product-detail/ProductDetailInfo.tsx new file mode 100644 index 0000000..25eaefc --- /dev/null +++ b/components/product-detail/ProductDetailInfo.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { Product, ProductVariant } from './ProductDetail.tsx'; +import { Button } from '../ui/button'; +import { + RiSubtractLine, + RiAddLine, + RiTruckLine, + RiArrowGoBackLine, + RiSecurePaymentLine, + RiLoader4Line, +} from '@remixicon/react'; + +interface ProductDetailInfoProps { + product: Product; + selectedVariant: ProductVariant | null; + selectedOptions: Record; + quantity: number; + setQuantity: (quantity: number) => void; + handleAddToCart: () => void; + onOptionChange: (optionName: string, value: string) => void; + isAddingToCart?: boolean; +} + +const ProductDetailInfo: React.FC = ({ + product, + selectedVariant, + selectedOptions, + quantity, + setQuantity, + handleAddToCart, + onOptionChange, + isAddingToCart = 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 + .filter(option => option.name !== 'Title') + .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/product-detail/ProductRecommendations.tsx b/components/product-detail/ProductRecommendations.tsx new file mode 100644 index 0000000..da6845b --- /dev/null +++ b/components/product-detail/ProductRecommendations.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { getProductRecommendations } from '@/hooks/use-shopify-products'; +import ProductCard from '../ProductCard'; + +interface ProductRecommendationsProps { + productId: string; +} + +const ProductRecommendations: React.FC = ({ productId }) => { + const [recommendedProducts, setRecommendedProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRecommendations = async () => { + if (!productId) return; + + try { + setLoading(true); + setError(null); + + const recommendations = await getProductRecommendations(productId); + setRecommendedProducts(recommendations); + } catch (err) { + console.error('Error fetching product recommendations:', err); + setError(err instanceof Error ? err.message : 'Failed to load recommendations'); + } finally { + setLoading(false); + } + }; + + fetchRecommendations(); + }, [productId]); + + // Don't show section if we're not loading and have no recommendations + if (!loading && (!recommendedProducts || recommendedProducts.length === 0)) { + return null; + } + + return ( +
+
+

+ You Might Also Like +

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

Recommendations could not be loaded

+
+ ) : ( +
+ {recommendedProducts.slice(0, 4).map((recommendedProduct) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default ProductRecommendations; \ No newline at end of file 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