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 54f0b21..96489b0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,11 @@ -import Header from '@/components/Header'; -import Footer from '@/components/Footer'; -import ProductDetail from '@/components/product-detail/ProductDetail'; +import React from 'react'; +import ProductDetail from '@/components/shopify/product-detail'; import config from '@/lib/config.json'; -export default function Page() { - const productHandle = config.data.product; - +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.json b/components.json deleted file mode 100644 index b7b9791..0000000 --- a/components.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$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 deleted file mode 100644 index ec4c512..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 9ef10cf..0000000 --- a/components/CollectionDetail.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"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 deleted file mode 100644 index 157216a..0000000 --- a/components/Collections.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"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 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 fe4a795..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/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/Products.tsx b/components/Products.tsx deleted file mode 100644 index e7e0549..0000000 --- a/components/Products.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"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 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/shopify/collections.tsx b/components/shopify/collections.tsx new file mode 100644 index 0000000..7d29577 --- /dev/null +++ b/components/shopify/collections.tsx @@ -0,0 +1,100 @@ +'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..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 65% rename from components/product-detail/ProductDetailInfo.tsx rename to components/shopify/product-detail/product-detail-info.tsx index 25eaefc..360483a 100644 --- a/components/product-detail/ProductDetailInfo.tsx +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -1,13 +1,14 @@ import React from '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, - RiLoader4Line, } from '@remixicon/react'; interface ProductDetailInfoProps { @@ -18,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 = ({ @@ -29,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', { @@ -44,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 -
+ )}
@@ -76,34 +77,34 @@ 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 */}
-
+
+
+ ); + } + + 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/promo-banner.tsx b/components/shopify/promo-banner.tsx new file mode 100644 index 0000000..57729c1 --- /dev/null +++ b/components/shopify/promo-banner.tsx @@ -0,0 +1,55 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} from '@/components/ui/carousel'; + +const PROMOS = [ + { text: 'Send a Gift Card', href: '/' }, + { text: 'Free shipping on orders over $100', href: '/' }, + { text: '30-day return policy', href: '/' }, +]; + +const PromoBanner: React.FC = () => { + return ( +
+ + + {PROMOS.map((promo) => ( + +
+ + {promo.text} + +
+
+ ))} +
+ + + + + + +
+
+ ); +}; + +export default PromoBanner; diff --git a/components/shopify/search-dialog.tsx b/components/shopify/search-dialog.tsx new file mode 100644 index 0000000..8ebba67 --- /dev/null +++ b/components/shopify/search-dialog.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Command } from 'cmdk'; +import { RiSearchLine } from '@remixicon/react'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Loader } from '@/components/ui/loader'; +import { getProducts, type Product } from '@/hooks/use-shopify-products'; + +interface SearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const SearchDialog: React.FC = ({ open, onOpenChange }) => { + const router = useRouter(); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(''); + + // Load the catalog once per open; cmdk filters it as the user types + useEffect(() => { + if (!open) { + setQuery(''); + return; + } + + let cancelled = false; + setLoading(true); + getProducts({ first: 100, sortKey: 'TITLE' }) + .then((data) => { + if (!cancelled) setProducts(data); + }) + .catch((err) => { + console.error('Failed to load products for search:', err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open]); + + const handleSelect = (handle: string) => { + onOpenChange(false); + router.push(`/products/${handle}`); + }; + + return ( + + + Search products + +
+ + +
+ + {loading ? ( +
+ +
+ ) : ( + <> + + No products found. + + {products.map((product) => { + const image = product.images.edges[0]?.node; + const price = product.priceRange.minVariantPrice; + + return ( + handleSelect(product.handle)} + className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm data-[selected=true]:bg-gray-100" + > +
+ {image && ( + {image.altText + )} +
+ + {product.title} + + + ${parseFloat(price.amount).toFixed(2)} + +
+ ); + })} + + )} +
+
+
+
+ ); +}; + +export default SearchDialog; diff --git a/components/shopify/shop-footer.tsx b/components/shopify/shop-footer.tsx new file mode 100644 index 0000000..dc848f8 --- /dev/null +++ b/components/shopify/shop-footer.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +const Footer: React.FC = () => { + return ( +
+
+ + Store + +

© 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..6f9f2bb --- /dev/null +++ b/components/shopify/shop-header.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { useShopifyCart } from '@/hooks/use-shopify-cart'; +import config from '@/lib/config.json'; +import { RiSearchLine, RiShoppingBagLine } from '@remixicon/react'; +import SearchDialog from './search-dialog'; + +const CartIcon: React.FC = () => { + const { toggleCart, itemCount } = useShopifyCart(); + + return ( + + ); +}; + +const Header: React.FC = () => { + const [searchOpen, setSearchOpen] = useState(false); + + return ( + + ); +}; + +export default Header; diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx index 9d3232c..4a8cca4 100644 --- a/components/ui/accordion.tsx +++ b/components/ui/accordion.tsx @@ -1,197 +1,66 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; -import { cn } from '@/lib/utils'; +"use client" -interface AccordionContextType { - value: string | string[]; - onValueChange: (value: string) => void; - type: 'single' | 'multiple'; -} +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" -const AccordionContext = createContext( - undefined -); - -interface AccordionItemContextType { - value: string; -} - -const AccordionItemContext = createContext< - AccordionItemContextType | undefined ->(undefined); - -function useAccordion() { - const context = useContext(AccordionContext); - if (!context) { - throw new Error('Accordion components must be used within an Accordion'); - } - return context; -} - -function useAccordionItem() { - const context = useContext(AccordionItemContext); - if (!context) { - throw new Error( - 'AccordionTrigger and AccordionContent must be used within an AccordionItem' - ); - } - return context; -} - -interface AccordionProps { - type?: 'single' | 'multiple'; - value?: string | string[]; - onValueChange?: (value: string | string[]) => void; - children: React.ReactNode; -} +import { cn } from "@/lib/utils" function Accordion({ - type = 'single', - value: controlledValue, - onValueChange, - children, -}: AccordionProps) { - const [internalValue, setInternalValue] = useState( - type === 'single' ? '' : [] - ); - - const isControlled = controlledValue !== undefined; - const value = isControlled ? controlledValue : internalValue; - - const handleValueChange = useCallback( - (itemValue: string) => { - if (type === 'single') { - const newValue = value === itemValue ? '' : itemValue; - if (!isControlled) { - setInternalValue(newValue); - } - onValueChange?.(newValue); - } else { - const valueArray = Array.isArray(value) ? value : []; - const newValue = valueArray.includes(itemValue) - ? valueArray.filter((v) => v !== itemValue) - : [...valueArray, itemValue]; - if (!isControlled) { - setInternalValue(newValue); - } - onValueChange?.(newValue); - } - }, - [value, type, isControlled, onValueChange] - ); + ...props +}: React.ComponentProps) { + return +} +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { return ( - -
{children}
-
- ); -} - -interface AccordionItemProps { - value: string; - children: React.ReactNode; - className?: string; -} - -function AccordionItem({ value, children, className }: AccordionItemProps) { - return ( - -
- {children} -
-
- ); -} - -interface AccordionTriggerProps extends React.ButtonHTMLAttributes { - children: React.ReactNode; + + ) } function AccordionTrigger({ className, children, ...props -}: AccordionTriggerProps) { - const accordion = useAccordion(); - const item = useAccordionItem(); - - const handleClick = () => { - accordion.onValueChange(item.value); - }; - - const isOpen = - accordion.type === 'single' - ? accordion.value === item.value - : Array.isArray(accordion.value) && accordion.value.includes(item.value); - +}: React.ComponentProps) { return ( -
- -
- ); -} - -interface AccordionContentProps extends React.HTMLAttributes { - children: React.ReactNode; + + + + ) } function AccordionContent({ className, children, ...props -}: AccordionContentProps) { - const accordion = useAccordion(); - const item = useAccordionItem(); - - const isOpen = - accordion.type === 'single' - ? accordion.value === item.value - : Array.isArray(accordion.value) && accordion.value.includes(item.value); - +}: React.ComponentProps) { return ( -
-
{children}
-
- ); +
{children}
+ + ) } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4dbb919 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +
+ +
+
+)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx index 3c5a3e4..1421354 100644 --- a/components/ui/alert.tsx +++ b/components/ui/alert.tsx @@ -1,28 +1,34 @@ import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" -type AlertVariant = "default" | "destructive" - -const baseClasses = - "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current" - -const variantClasses: Record = { - default: "bg-card text-card-foreground", - destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", -} +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) function Alert({ className, - variant = "default", + variant, ...props -}: React.ComponentProps<"div"> & { variant?: AlertVariant }) { +}: React.ComponentProps<"div"> & VariantProps) { return (
) diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 6cd2e95..a38fe5d 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,81 +1,109 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; +"use client" -interface AvatarProps extends React.ComponentProps<'div'> { - size?: 'sm' | 'md' | 'lg' | 'xl'; -} +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" -const sizeClasses = { - sm: 'size-6', - md: 'size-8', - lg: 'size-10', - xl: 'size-12', -}; - -function Avatar({ className, size = 'md', ...props }: AvatarProps) { - const [imageError, setImageError] = React.useState(false); +import { cn } from "@/lib/utils" +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { return ( -
- ); + ) } function AvatarImage({ className, - onError, ...props -}: React.ComponentProps<'img'>) { - const [hasError, setHasError] = React.useState(false); - - const handleError = (e: React.SyntheticEvent) => { - setHasError(true); - onError?.(e as any); - }; - - if (hasError) { - return null; - } - +}: React.ComponentProps) { return ( - - ); -} - -interface AvatarFallbackProps extends React.ComponentProps<'div'> { - children: React.ReactNode; + ) } function AvatarFallback({ className, - children, ...props -}: AvatarFallbackProps) { +}: React.ComponentProps) { return ( -
- {children} -
- ); + /> + ) } -export { Avatar, AvatarImage, AvatarFallback }; -export type { AvatarProps }; +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index ef46f46..ba40cc1 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,53 +1,48 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; +import { cn } from "@/lib/utils" -interface BadgeProps extends React.ComponentProps<'span'> { - variant?: BadgeVariant; - asChild?: boolean; -} - -const badgeVariants: Record = { - default: - 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90', - secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90', - destructive: - 'border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', - outline: - 'text-foreground border-border hover:bg-accent hover:text-accent-foreground', -}; +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) function Badge({ className, - variant = 'default', + variant = "default", asChild = false, - children, ...props -}: BadgeProps) { - const baseClasses = cn( - 'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden', - '[&>svg]:size-3 [&>svg]:pointer-events-none [&>svg]:shrink-0' - ); - - const variantClasses = badgeVariants[variant]; - - const finalClassName = cn(baseClasses, variantClasses, className); - - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement, { - className: cn(children.props.className, finalClassName), - ...props, - }); - } +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" return ( - - {children} - - ); + + ) } -export { Badge, badgeVariants }; -export type { BadgeProps }; +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx index cbe7189..eb88f32 100644 --- a/components/ui/breadcrumb.tsx +++ b/components/ui/breadcrumb.tsx @@ -1,140 +1,101 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" -function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { - return