commit 1fb1df8cfdef1d85c1d9c763f4b25bd4fa28c080 Author: Rami Bitar Date: Sun Apr 19 11:15:11 2026 -0400 Initial commit 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..0a218b6 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,105 @@ +@import 'tailwindcss'; +@import "tw-animate-css"; + +@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%); +} + +/* Set border color for all elements */ +*, +::after, +::before, +::backdrop, +::file-selector-button { + border-color: var(--color-border); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..0b75872 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +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'; + +export const metadata: Metadata = { + title: 'Frontend | Shopify', + description: 'Frontend Shopify Storefront', +}; + +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..fcc3f2a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Products from '@/components/shopify/products'; + +const Home: React.FC = () => { + return ( + + ); +}; + +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/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..1327d8f --- /dev/null +++ b/components/shopify/cart-drawer.tsx @@ -0,0 +1,216 @@ +'use client'; + +import React from 'react'; +import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart'; +import { Button } from '@/components/ui/button'; +import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from '@/components/ui/empty'; +import { Spinner } from '@/components/ui/spinner'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + RiCloseLine, + RiImageLine, + RiSubtractLine, + RiAddLine, +} from '@remixicon/react'; + +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()}> + + {/* 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..d176aa1 --- /dev/null +++ b/components/shopify/product-detail/index.tsx @@ -0,0 +1,212 @@ +'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 { RiErrorWarningLine } from '@remixicon/react'; +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..a74f55e --- /dev/null +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { RiImageLine } from '@remixicon/react'; + +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..3fd5808 --- /dev/null +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -0,0 +1,165 @@ +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'; +import { + RiSubtractLine, + RiAddLine, + RiTruckLine, + RiArrowGoBackLine, + RiSecurePaymentLine, +} 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; + 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..a959196 --- /dev/null +++ b/components/shopify/shop-header.tsx @@ -0,0 +1,69 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import config from '@/lib/config.json'; +import { RiShoppingCartLine } from '@remixicon/react'; + +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..4a8cca4 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4dbb919 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +
+ +
+
+)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + 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/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..a38fe5d --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..ba40cc1 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return