Initial commit

This commit is contained in:
Rami Bitar
2026-04-19 11:15:11 -04:00
commit 1fb1df8cfd
98 changed files with 13291 additions and 0 deletions

5
app/api/hello/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
return NextResponse.json({ message: 'Hello, World' });
}

View File

@@ -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 (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/collections">Collections</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<CollectionDetail />
</div>
);
}

36
app/collections/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Collections</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<Collections />
</div>
);
}

25
app/error.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import React from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
{error.message}
</div>
</div>
</div>
);
}

105
app/globals.css Normal file
View File

@@ -0,0 +1,105 @@
@import 'tailwindcss';
@import "tw-animate-css";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Background and foreground */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
/* Card */
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
/* Popover */
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
/* Primary */
--color-primary: hsl(222.2 47.4% 11.2%);
--color-primary-foreground: hsl(210 40% 98%);
/* Secondary */
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
/* Muted */
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
/* Accent */
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
/* Destructive */
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
/* Border, input, ring */
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(222.2 84% 4.9%);
/* Sidebar */
--color-sidebar: hsl(0 0% 98%);
--color-sidebar-foreground: hsl(240 5.3% 26.1%);
--color-sidebar-primary: hsl(240 5.9% 10%);
--color-sidebar-primary-foreground: hsl(0 0% 98%);
--color-sidebar-accent: hsl(240 4.8% 95.9%);
--color-sidebar-accent-foreground: hsl(240 5.9% 10%);
--color-sidebar-border: hsl(220 13% 91%);
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
/* Fonts */
--font-heading: 'Space Grotesk', sans-serif;
--font-body: 'Inter', sans-serif;
}
/* Dark mode */
.dark {
--color-background: hsl(222.2 84% 4.9%);
--color-foreground: hsl(210 40% 98%);
--color-card: hsl(222.2 84% 4.9%);
--color-card-foreground: hsl(210 40% 98%);
--color-popover: hsl(222.2 84% 4.9%);
--color-popover-foreground: hsl(210 40% 98%);
--color-primary: hsl(210 40% 98%);
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
--color-secondary: hsl(217.2 32.6% 17.5%);
--color-secondary-foreground: hsl(210 40% 98%);
--color-muted: hsl(217.2 32.6% 17.5%);
--color-muted-foreground: hsl(215 20.2% 65.1%);
--color-accent: hsl(217.2 32.6% 17.5%);
--color-accent-foreground: hsl(210 40% 98%);
--color-destructive: hsl(0 62.8% 30.6%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(217.2 32.6% 17.5%);
--color-input: hsl(217.2 32.6% 17.5%);
--color-ring: hsl(212.7 26.8% 83.9%);
--color-sidebar: hsl(240 5.9% 10%);
--color-sidebar-foreground: hsl(240 4.8% 95.9%);
--color-sidebar-primary: hsl(224.3 76.3% 48%);
--color-sidebar-primary-foreground: hsl(0 0% 100%);
--color-sidebar-accent: hsl(240 3.7% 15.9%);
--color-sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--color-sidebar-border: hsl(240 3.7% 15.9%);
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
}
/* Set border color for all elements */
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border);
}

34
app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React from 'react';
import type { Metadata } from 'next';
import './globals.css';
import { Providers } from './providers';
import ShopifyCart from '@/components/shopify/cart-drawer';
import ShopHeader from '@/components/shopify/shop-header';
import ShopFooter from '@/components/shopify/shop-footer';
export const metadata: Metadata = {
title: 'Frontend | Shopify',
description: 'Frontend Shopify Storefront',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head></head>
<body className="m-0 p-0 font-body">
<Providers>
<ShopHeader />
<main className="min-h-screen">
{children}
</main>
<ShopFooter />
<ShopifyCart />
</Providers>
</body>
</html>
);
}

20
app/not-found.tsx Normal file
View File

@@ -0,0 +1,20 @@
"use client";
import React from 'react';
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
This page could not be found.
</div>
</div>
</div>
);
}

10
app/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import Products from '@/components/shopify/products';
const Home: React.FC = () => {
return (
<Products />
);
};
export default Home;

View File

@@ -0,0 +1,197 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from '@/components/shopify/product-detail/product-detail-gallery';
import ProductDetailInfo from '@/components/shopify/product-detail/product-detail-info';
import ProductRecommendations from '@/components/shopify/product-detail/product-recommendations';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
interface ProductVariant {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: {
url: string;
altText?: string;
};
}
export default function ProductDetailPage() {
const params = useParams();
const handle = params.handle as string;
const { addItem, openCart } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
const [quantity, setQuantity] = useState(1);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [addingToCart, setAddingToCart] = useState(false);
// Initialize variant when product loads
useEffect(() => {
if (product) {
const firstVariant = product.variants.edges[0]?.node;
if (firstVariant) {
setSelectedVariant(firstVariant);
const initialOptions: Record<string, string> = {};
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
initialOptions[option.name] = option.value;
});
setSelectedOptions(initialOptions);
}
}
}, [product]);
const handleOptionChange = (optionName: string, value: string) => {
const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions);
// Find matching variant
const matchingVariant = product?.variants.edges.find(({ node }) => {
return node.selectedOptions.every(option =>
newOptions[option.name] === option.value
);
});
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
// Update image if variant has an associated image
if (matchingVariant.node.image && product) {
const variantImageUrl = matchingVariant.node.image.url;
const imageIndex = product.images.edges.findIndex(
edge => edge.node.url === variantImageUrl
);
if (imageIndex !== -1) {
setSelectedImageIndex(imageIndex);
}
}
}
};
const handleAddToCart = async () => {
if (!selectedVariant || !product) return;
try {
setAddingToCart(true);
await addItem(selectedVariant.id, quantity);
openCart();
} catch (err) {
console.error('Failed to add item to cart:', err);
} finally {
setAddingToCart(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image Gallery Skeleton */}
<div>
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div>
))}
</div>
</div>
{/* Product Info Skeleton */}
<div>
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div>
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<Alert variant="destructive" className="max-w-md mx-auto p-8">
<i className="ri-error-warning-line text-4xl"></i>
<AlertTitle className="text-lg font-semibold">
Product Not Found
</AlertTitle>
<AlertDescription className="mb-4">
{error || 'The requested product could not be found.'}
</AlertDescription>
<Button
onClick={() => window.history.back()}
variant="destructive"
>
Go Back
</Button>
</Alert>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{product.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<ProductDetailGallery
images={product.images.edges.map(edge => edge.node)}
selectedImageIndex={selectedImageIndex}
onImageSelect={setSelectedImageIndex}
/>
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
onOptionChange={handleOptionChange}
loading={addingToCart}
/>
</div>
</div>
<ProductRecommendations productId={product.id} />
</div>
);
}

12
app/providers.tsx Normal file
View File

@@ -0,0 +1,12 @@
'use client';
import React from 'react';
import { ShopifyProvider } from '@/contexts/shopify-context';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ShopifyProvider>
{children}
</ShopifyProvider>
);
}