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 index 86f34a6..b3805e1 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,5 +1,7 @@ "use client"; +import React from 'react'; + export default function Error({ error, reset, diff --git a/app/globals.css b/app/globals.css index e99bfe1..abaed60 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,8 +1,10 @@ @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%); @@ -16,16 +18,16 @@ --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%); + --color-primary: hsl(0 0% 0%); + --color-primary-foreground: hsl(0 0% 100%); /* 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%); + --color-muted: hsl(0 0% 96.1%); + --color-muted-foreground: hsl(0 0% 45%); /* Accent */ --color-accent: hsl(210 40% 96.1%); @@ -57,8 +59,9 @@ --radius-xl: 0.75rem; /* Fonts */ - --font-heading: "Space Grotesk", sans-serif; - --font-body: "Inter", sans-serif; + --font-heading: var(--font-poppins), 'Poppins', sans-serif; + --font-body: 'Inter', sans-serif; + } /* Dark mode */ @@ -91,3 +94,12 @@ --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 index 05d8255..7ea8014 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,22 @@ +import React from 'react'; 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 { Poppins } from 'next/font/google'; import './globals.css'; +import { Providers } from './providers'; +import ShopifyCart from '@/components/shopify/cart-drawer'; +import ShopHeader from '@/components/shopify/shop-header'; +import PromoBanner from '@/components/shopify/promo-banner'; +import ShopFooter from '@/components/shopify/shop-footer'; + +const poppins = Poppins({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], + variable: '--font-poppins', +}); export const metadata: Metadata = { - title: 'Frontend', - description: 'Start prompting', + title: 'Frontend | Shopify', + description: 'Frontend Shopify Storefront', }; export default function RootLayout({ @@ -16,19 +25,18 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - - - + + - - -
+ + + +
{children} -
- - -
+ + + + ); diff --git a/app/not-found.tsx b/app/not-found.tsx index 5a5ae5d..3692439 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,5 +1,8 @@ "use client"; +import React from 'react'; +import Link from 'next/link'; + export default function NotFound() { return (
diff --git a/app/page.tsx b/app/page.tsx index 6b99b5c..e51c24f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,11 @@ -import Header from '@/components/Header'; -import Footer from '@/components/Footer'; -import CollectionDetail from '@/components/CollectionDetail'; +import React from 'react'; +import CollectionDetail from '@/components/shopify/collection-detail'; import config from '@/lib/config.json'; -export default function Home() { - const collectionHandle = config.data.collection; - +const Home: React.FC = () => { return ( -
-
-
- -
-
-
+ ); -} +}; + +export default Home; diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx new file mode 100644 index 0000000..e8fbf8f --- /dev/null +++ b/app/products/[handle]/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import ProductDetail from '@/components/shopify/product-detail'; + +export default function ProductDetailPage() { + return ; +} 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/CartDrawer.tsx b/components/CartDrawer.tsx deleted file mode 100644 index b456d22..0000000 --- a/components/CartDrawer.tsx +++ /dev/null @@ -1,206 +0,0 @@ -"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/CollectionDetail.tsx b/components/CollectionDetail.tsx deleted file mode 100644 index 88e7e52..0000000 --- a/components/CollectionDetail.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import React, { useState } from 'react'; -import { useCollectionProducts } 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 CollectionDetailProps { - collectionHandle?: string; -} - -const CollectionDetail: React.FC = ({ collectionHandle }) => { - const { collection, loading, error } = useCollectionProducts(collectionHandle || null, { - first: 20, - sortKey: 'BEST_SELLING', - reverse: false - }); - const [selectedProduct, setSelectedProduct] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - - if (loading) { - return ( -
-
-

- Collection -

- - {/* Loading Skeleton */} -
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
-
-
-
- ))} -
-
-
- ); - } - - if (error || !collection) { - 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 */} -
-
- {collection.products.length === 0 ? ( -
-
- -

- No Products in Collection -

-

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

-
-
- ) : ( -
- {collection.products.map((product) => ( - { - setSelectedProduct(product); - setIsModalOpen(true); - }} - /> - ))} -
- )} -
-
- - {/* Product Modal */} - {selectedProduct && ( - { - setIsModalOpen(false); - setSelectedProduct(null); - }} - /> - )} -
- ); -}; - -export default CollectionDetail; diff --git a/components/Footer.tsx b/components/Footer.tsx deleted file mode 100644 index 4b8c70b..0000000 --- a/components/Footer.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 71e4527..0000000 --- a/components/Header.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"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/Home.tsx b/components/Home.tsx deleted file mode 100644 index 38aff1f..0000000 --- a/components/Home.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import Header from './Header'; -import Footer from './Footer'; -import CollectionDetail from './CollectionDetail'; -import config from '../lib/config.json'; - -const Home: React.FC = () => { - const collectionHandle = config.data.collection; - - return ( -
-
-
- -
-
-
- ); -}; - -export default Home; \ No newline at end of file diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx deleted file mode 100644 index eb7db0a..0000000 --- a/components/ProductCard.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"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 deleted file mode 100644 index b3e9e0d..0000000 --- a/components/ProductModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"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/Theme.tsx b/components/Theme.tsx deleted file mode 100644 index 1e5c73f..0000000 --- a/components/Theme.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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/product-detail/ProductDetail.tsx b/components/product-detail/ProductDetail.tsx deleted file mode 100644 index f04958b..0000000 --- a/components/product-detail/ProductDetail.tsx +++ /dev/null @@ -1,240 +0,0 @@ -"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 deleted file mode 100644 index 578aa34..0000000 --- a/components/product-detail/ProductDetailGallery.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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/ProductRecommendations.tsx b/components/product-detail/ProductRecommendations.tsx deleted file mode 100644 index da6845b..0000000 --- a/components/product-detail/ProductRecommendations.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"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/shopify/cart-drawer.tsx b/components/shopify/cart-drawer.tsx new file mode 100644 index 0000000..a8b1550 --- /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 { Loader } from '@/components/ui/loader'; +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/CollectionCard.tsx b/components/shopify/collection-card.tsx similarity index 56% rename from components/CollectionCard.tsx rename to components/shopify/collection-card.tsx index 591a9c7..c9b8a9f 100644 --- a/components/CollectionCard.tsx +++ b/components/shopify/collection-card.tsx @@ -20,10 +20,7 @@ interface CollectionCardProps { const CollectionCard: React.FC = ({ collection }) => { return ( - + {/* Collection Image */}
{collection.image ? ( @@ -40,25 +37,13 @@ const CollectionCard: React.FC = ({ collection }) => {
{/* 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 +export default CollectionCard; diff --git a/components/shopify/collection-detail.tsx b/components/shopify/collection-detail.tsx new file mode 100644 index 0000000..56b57f3 --- /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'; + +interface CollectionDetailProps { + handle?: string; +} + +const CollectionDetail: React.FC = ({ handle: handleProp }) => { + const params = useParams(); + const handle = handleProp || (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: 6 }).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/Collections.tsx b/components/shopify/collections.tsx similarity index 57% rename from components/Collections.tsx rename to components/shopify/collections.tsx index ecc535d..7d29577 100644 --- a/components/Collections.tsx +++ b/components/shopify/collections.tsx @@ -1,31 +1,26 @@ -"use client"; +'use client'; import React from 'react'; import { useCollections } from '@/hooks/use-shopify-collections'; -import CollectionCard from './CollectionCard'; -import { Button } from './ui/button'; +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) => ( -
+
-
-
-
-
-
+
))}
@@ -36,26 +31,26 @@ const Collections: React.FC = () => { if (error) { return ( -
-
-

+
+
+

Our Collections

-
+
-

+

Failed to Load Collections

{error}

- +
@@ -64,15 +59,15 @@ const Collections: React.FC = () => { if (collections.length === 0) { return ( -
-
-

+
+
+

Our Collections

-
+
-

+

No Collections Found

@@ -85,9 +80,9 @@ const Collections: React.FC = () => { } return ( -

-
-

+
+
+

Our Collections

diff --git a/components/shopify/product-card.tsx b/components/shopify/product-card.tsx new file mode 100644 index 0000000..339a033 --- /dev/null +++ b/components/shopify/product-card.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import Link from 'next/link'; +import { useShopifyCart, addCartLines } from '@/hooks/use-shopify-cart'; +import { truncate } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; + +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; + productType?: 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)} +

+ +

+ ${parseFloat(price.amount).toFixed(2)} +

+
+
+ ); +}; + +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..936583f --- /dev/null +++ b/components/shopify/product-detail.tsx @@ -0,0 +1,3 @@ +import ProductDetail from './product-detail/index'; + +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..ae2d7e0 --- /dev/null +++ b/components/shopify/product-detail/index.tsx @@ -0,0 +1,193 @@ +'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, ProductVariant }; + +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 [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); + } + }; + + 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)} + /> +
+
+ +
+
+
+ + +
+ ); +}; + +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..de248bd --- /dev/null +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -0,0 +1,73 @@ +'use client'; + +import React, { useState } from 'react'; +import { RiImageLine } from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductDetailGalleryProps { + images: ProductImage[]; +} + +const ProductDetailGallery: React.FC = ({ images }) => { + const [zoomedIndex, setZoomedIndex] = useState(null); + + if (images.length === 0) { + return ( +
+ +
+ ); + } + + const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null; + + return ( + <> +
+ {images.map((image, index) => ( + + ))} +
+ + {/* Zoom Lightbox */} + !open && setZoomedIndex(null)} + > + + Product image + {zoomedImage && ( + {zoomedImage.altText + )} + + + + ); +}; + +export default ProductDetailGallery; diff --git a/components/product-detail/ProductDetailInfo.tsx b/components/shopify/product-detail/product-detail-info.tsx similarity index 59% rename from components/product-detail/ProductDetailInfo.tsx rename to components/shopify/product-detail/product-detail-info.tsx index 5b707d2..360483a 100644 --- a/components/product-detail/ProductDetailInfo.tsx +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -1,7 +1,15 @@ import React from 'react'; -import { RiSubtractLine, RiAddLine, RiTruckLine, RiArrowGoBackLine, RiSecurePaymentLine, RiLoader4Line } from '@remixicon/react'; -import { Product, ProductVariant } from './ProductDetail.tsx'; -import { Button } from '../ui/button'; +import { Product, ProductVariant } from './index'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader } from '@/components/ui/loader'; +import { + RiSubtractLine, + RiAddLine, + RiTruckLine, + RiArrowGoBackLine, + RiSecurePaymentLine, +} from '@remixicon/react'; interface ProductDetailInfoProps { product: Product; @@ -11,7 +19,7 @@ interface ProductDetailInfoProps { setQuantity: (quantity: number) => void; handleAddToCart: () => void; onOptionChange: (optionName: string, value: string) => void; - isAddingToCart?: boolean; + loading?: boolean; } const ProductDetailInfo: React.FC = ({ @@ -22,7 +30,7 @@ const ProductDetailInfo: React.FC = ({ setQuantity, handleAddToCart, onOptionChange, - isAddingToCart = false + loading = false, }) => { const formatPrice = (price: { amount: string; currencyCode: string }) => { return new Intl.NumberFormat('en-US', { @@ -37,23 +45,23 @@ const ProductDetailInfo: React.FC = ({ return (
-

+

{product.title}

{/* Price */} -
- +
+ {formatPrice(price)} {hasDiscount && compareAtPrice && ( <> - + {formatPrice(compareAtPrice)} -
+ {Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF -
+ )}
@@ -69,67 +77,67 @@ const ProductDetailInfo: React.FC = ({
)} - {/* Product Options */} + {/* Product Options (skip Shopify's placeholder "Title: Default Title" option) */} {product.options - .filter(option => option.name !== 'Title') + .filter(option => !(option.name === 'Title' && option.values.length === 1 && option.values[0] === 'Default Title')) .map(option => ( -
- -
- {option.values.map(value => ( - - ))} -
+
+ +
+ {option.values.map(value => ( + + ))}
- ))} +
+ ))} {/* Quantity Selector */}
-