chore: transfer repo
This commit is contained in:
34
components/Label.tsx
Normal file
34
components/Label.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import clsx from 'clsx';
|
||||
import Price from './price';
|
||||
|
||||
const Label = ({
|
||||
title,
|
||||
amount,
|
||||
currencyCode,
|
||||
position = 'bottom'
|
||||
}: {
|
||||
title: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
position?: 'bottom' | 'center';
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center bg-white">
|
||||
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
|
||||
<Price
|
||||
className=""
|
||||
amount={amount}
|
||||
currencyCode={currencyCode}
|
||||
currencyCodeClassName="hidden @[275px]/label:inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Label;
|
||||
15
components/LoadingDots.tsx
Normal file
15
components/LoadingDots.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
|
||||
|
||||
const LoadingDots = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<span className="mx-2 inline-flex items-center">
|
||||
<span className={clsx(dots, className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
|
||||
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingDots;
|
||||
104
components/about/AboutPageContent.tsx
Normal file
104
components/about/AboutPageContent.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { ContactForm } from '@/components/ui/ContactForm';
|
||||
import { Newsletter } from '@/components/ui/Newsletter';
|
||||
import { Section } from '@/components/ui/Section';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { container } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function AboutPageContent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* About Us Header Section */}
|
||||
<Section spacing="large">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Heading level={1} className="mb-6 tracking-[0]">{t('about.title')}</Heading>
|
||||
<Text className="max-w-3xl mx-auto mb-12 tracking-[0] mb-[80px]">
|
||||
{t('about.description')}
|
||||
</Text>
|
||||
|
||||
<div className="w-full aspect-[16/9] relative bg-gray-100">
|
||||
<Image
|
||||
src="/assets/images/image2.webp"
|
||||
alt={t('about.title')}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Contact Form Section */}
|
||||
<div className="relative">
|
||||
{/* Top wave */}
|
||||
<div className="w-full">
|
||||
<Image
|
||||
src="/assets/images/Frame6.png"
|
||||
alt=""
|
||||
width={1440}
|
||||
height={140}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content with yellow background */}
|
||||
<div className="bg-[#FFC877] pb-[224px]">
|
||||
<div className={container}>
|
||||
<div className="py-[112px]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div>
|
||||
<ContactForm
|
||||
title={t('about.contactForm.title')}
|
||||
subtitle={t('about.contactForm.subtitle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full relative bg-gray-100 min-h-[600px]">
|
||||
<Image
|
||||
src="/assets/images/image22.png"
|
||||
alt={t('about.contactForm.title')}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<div className="relative -mt-[112px]">
|
||||
{/* Top wave that overlaps with yellow section */}
|
||||
<div className="w-full absolute -top-32 z-10">
|
||||
<Image
|
||||
src="/assets/images/Frame4.png"
|
||||
alt=""
|
||||
width={1440}
|
||||
height={140}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content with pink background */}
|
||||
<div className="bg-[#F58EA7]">
|
||||
<div className={container}>
|
||||
<div className="py-[112px]">
|
||||
<Newsletter
|
||||
title={t('about.newsletter.title')}
|
||||
subtitle={t('about.newsletter.subtitle')}
|
||||
buttonText={t('about.newsletter.buttonText')}
|
||||
disclaimer={t('about.newsletter.disclaimer')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
173
components/build-box/AddBoxToCartClient.tsx
Normal file
173
components/build-box/AddBoxToCartClient.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { clearBox, selectBoxItems, selectEditingBoxGroupId } from "@/lib/redux/slices/boxSlice";
|
||||
import { addItem, removeItem } from "components/cart/actions";
|
||||
import { useCart } from "components/cart/cart-context";
|
||||
import { Product, ProductVariant } from "lib/shopify/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function AddBoxToCartClient() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(true);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [boxProducts, setBoxProducts] = useState<Product[]>([]);
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const editingBoxGroupId = useAppSelector(selectEditingBoxGroupId);
|
||||
const { addCartItem, cart } = useCart();
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch products on mount
|
||||
useEffect(() => {
|
||||
async function fetchProducts() {
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
// Fetch regular products
|
||||
const regularResponse = await fetch('/api/products');
|
||||
const regularData = await regularResponse.json();
|
||||
|
||||
// Fetch box products
|
||||
const boxesResponse = await fetch('/api/products?type=boxes');
|
||||
const boxesData = await boxesResponse.json();
|
||||
|
||||
if (regularData.products) setAllProducts(regularData.products);
|
||||
if (boxesData.products) setBoxProducts(boxesData.products);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
// Check if we have a box
|
||||
const boxItems = items.filter(item => item.variantId === 'box-container');
|
||||
const productItems = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
const isEnabled = boxItems.length > 0 && !isLoadingProducts;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (isLoading || !isEnabled) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Save the current box state to localStorage for potential editing later
|
||||
try {
|
||||
const boxState = {
|
||||
boxItems: boxItems,
|
||||
productItems: productItems,
|
||||
boxGroupId: `box-${Date.now()}`
|
||||
};
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
} catch (error) {
|
||||
console.error("Failed to save box state:", error);
|
||||
}
|
||||
|
||||
// Generate a unique box ID to group all items together
|
||||
const boxGroupId = `box-${Date.now()}`;
|
||||
|
||||
// Ako je ovo uređivanje postojećeg boxa, prvo ukloni stari box i sve njegove proizvode
|
||||
if (editingBoxGroupId && cart) {
|
||||
// Pronađi sve proizvode i kontejner koji pripadaju ovom boxu
|
||||
const itemsToRemove = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === 'box_group_id')?.value;
|
||||
return itemBoxGroupId === editingBoxGroupId;
|
||||
});
|
||||
|
||||
// Ukloni sve pronađene proizvode
|
||||
for (const item of itemsToRemove) {
|
||||
if (item.id && item.merchandise.id) {
|
||||
await removeItem(null, item.merchandise.id, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add box items first
|
||||
for (const boxItem of boxItems) {
|
||||
// Find the actual box product from our pre-loaded products
|
||||
const boxProduct = boxProducts.find(p => p.id === boxItem.id);
|
||||
|
||||
if (boxProduct) {
|
||||
// Get the variant to use
|
||||
const boxVariant = boxProduct.variants[0];
|
||||
|
||||
if (boxVariant) {
|
||||
// Add box with attribute marking it as a box container and the box group ID
|
||||
await addItem(
|
||||
null,
|
||||
boxVariant.id,
|
||||
boxItem.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "container" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Update local cart state
|
||||
addCartItem(boxVariant, boxProduct, boxItem.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add product items
|
||||
for (const productItem of productItems) {
|
||||
// Find the actual product from our pre-loaded products
|
||||
const product = allProducts.find(p => p.id === productItem.id);
|
||||
|
||||
if (product) {
|
||||
// Find the variant
|
||||
let selectedVariant: ProductVariant | undefined;
|
||||
|
||||
if (productItem.variantId && productItem.variantId !== 'undefined') {
|
||||
selectedVariant = product.variants.find(v => v.id === productItem.variantId);
|
||||
}
|
||||
|
||||
// If no variant found, use the first one (exactly like AddToCartButton.tsx)
|
||||
if (!selectedVariant) {
|
||||
selectedVariant = product.variants[0];
|
||||
}
|
||||
|
||||
if (selectedVariant && selectedVariant.id) {
|
||||
// Add product with attribute marking it as a box item and the box group ID
|
||||
await addItem(
|
||||
null,
|
||||
selectedVariant.id,
|
||||
productItem.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "item" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Update local cart state
|
||||
addCartItem(selectedVariant, product, productItem.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(clearBox());
|
||||
} catch (error) {
|
||||
console.error("Failed to add box to cart:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isLoading || !isEnabled}
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
>
|
||||
{isLoading ? "Dodaje se..." : isLoadingProducts ? "Učitavanje..." : "Dodaj u košaricu"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
53
components/build-box/BuildBoxClientPage.tsx
Normal file
53
components/build-box/BuildBoxClientPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Text } from "@/components/ui/Typography";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import { container } from "@/lib/utils";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
import { ProductGrid } from "../products/ProductGrid";
|
||||
import { BuildBoxSidebar } from "./BuildBoxSidebar";
|
||||
|
||||
interface BuildBoxClientPageProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function BuildBoxClientPage({ products }: BuildBoxClientPageProps) {
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Main content */}
|
||||
<div className={`${container} lg:pr-[240px]`}>
|
||||
|
||||
<ProductGrid products={products} title="" />
|
||||
|
||||
{/* Add bottom padding on mobile to make room for mobile sidebar */}
|
||||
<div className="h-24 lg:hidden"></div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - right side, full height with scrolling */}
|
||||
<div className="hidden lg:block fixed top-0 right-0 w-[230px] h-full bg-gray-100 border-l border-gray-200 shadow-sm">
|
||||
<div className="pt-[88px] h-full overflow-y-auto">
|
||||
<BuildBoxSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - fixed at bottom of screen */}
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
|
||||
<div>
|
||||
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
<Link
|
||||
href="/build-box/customize"
|
||||
className="bg-primary text-white px-6 py-2 rounded disabled:bg-gray-400"
|
||||
aria-disabled={boxTotal === 0}
|
||||
tabIndex={boxTotal === 0 ? -1 : undefined}
|
||||
>
|
||||
Idući korak
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal file
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Box option interface
|
||||
export interface BoxOption {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface BoxCustomizeProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizeClient({ boxes }: BoxCustomizeProps) {
|
||||
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
|
||||
// Check if there's already a box in the items and set it as selected on load
|
||||
useEffect(() => {
|
||||
const existingBox = items.find(item => item.variantId === 'box-container');
|
||||
if (existingBox) {
|
||||
setSelectedBoxId(existingBox.id);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
// When a box is selected, replace any existing box with the new one
|
||||
const handleSelectBox = (box: BoxOption) => {
|
||||
// If the same box is already selected, do nothing
|
||||
if (selectedBoxId === box.id) return;
|
||||
|
||||
// Set this box as selected
|
||||
setSelectedBoxId(box.id);
|
||||
|
||||
// Remove any existing box containers first
|
||||
const existingBoxes = items.filter(item => item.variantId === 'box-container');
|
||||
existingBoxes.forEach(existingBox => {
|
||||
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
|
||||
});
|
||||
|
||||
// Add the new box
|
||||
dispatch(addToBox({
|
||||
id: box.id,
|
||||
name: box.title,
|
||||
price: box.price,
|
||||
image: box.image,
|
||||
quantity: 1,
|
||||
variantId: 'box-container', // Mark this as a box container, not a product
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{boxes.map((box) => (
|
||||
<BoxOption
|
||||
key={box.id}
|
||||
box={box}
|
||||
isSelected={selectedBoxId === box.id}
|
||||
onSelect={() => handleSelectBox(box)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Box option component
|
||||
function BoxOption({
|
||||
box,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
box: BoxOption;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`border rounded-md overflow-hidden ${isSelected ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="relative h-60 overflow-hidden">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300"
|
||||
/>
|
||||
{/* Overlay with "Dodaj u box" button - always visible for selected, only on hover for others */}
|
||||
<div className={`absolute inset-0 flex items-center justify-center ${isSelected ? 'bg-black/40' : 'bg-black/40 opacity-0 hover:opacity-100 transition-opacity duration-300'}`}>
|
||||
<Button
|
||||
variant="custom"
|
||||
onClick={onSelect}
|
||||
>
|
||||
Dodaj u box
|
||||
</Button>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary text-white rounded-full p-1 w-6 h-6 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-medium">{box.title}</h3>
|
||||
<span className="font-medium">${box.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
components/build-box/BuildBoxCustomizePage.tsx
Normal file
21
components/build-box/BuildBoxCustomizePage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getBoxProducts } from "lib/shopify";
|
||||
import { BoxOption } from "./BuildBoxCustomizeClient";
|
||||
import { BuildBoxCustomizePageContent } from "./client/BuildBoxCustomizePageContent";
|
||||
|
||||
export async function BuildBoxCustomizePage() {
|
||||
// Fetch boxes from the API
|
||||
const boxes = await getBoxProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
// Convert products to box options for display
|
||||
const boxOptions: BoxOption[] = boxes.map(box => ({
|
||||
id: box.id,
|
||||
title: box.title,
|
||||
price: parseFloat(box.priceRange.minVariantPrice.amount),
|
||||
image: box.featuredImage?.url || '/placeholder-box.jpg'
|
||||
}));
|
||||
|
||||
return <BuildBoxCustomizePageContent boxes={boxOptions} />;
|
||||
}
|
||||
24
components/build-box/BuildBoxMobileSummary.tsx
Normal file
24
components/build-box/BuildBoxMobileSummary.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Text } from "@/components/ui/Typography";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import Link from "next/link";
|
||||
|
||||
export function BuildBoxMobileSummary() {
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
|
||||
<div>
|
||||
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
<Link
|
||||
href="/build-box/customize"
|
||||
className={`px-6 py-2 rounded ${boxTotal > 0 ? 'bg-black text-white' : 'bg-gray-300 text-gray-500 pointer-events-none'}`}
|
||||
>
|
||||
Idući korak
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
components/build-box/BuildBoxPage.tsx
Normal file
12
components/build-box/BuildBoxPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getRegularProducts } from "lib/shopify";
|
||||
import { BuildBoxPageContent } from "./client/BuildBoxPageContent";
|
||||
|
||||
// Main server component
|
||||
export async function BuildBoxPage() {
|
||||
const products = await getRegularProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
return <BuildBoxPageContent products={products} />;
|
||||
}
|
||||
203
components/build-box/BuildBoxSidebar.tsx
Normal file
203
components/build-box/BuildBoxSidebar.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AddBoxToCartClient } from "./AddBoxToCartClient";
|
||||
|
||||
export function BuildBoxSidebar() {
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
const isEmpty = items.length === 0;
|
||||
const pathname = usePathname();
|
||||
|
||||
// Separate box containers from products
|
||||
const boxContainers = items.filter(item => item.variantId === 'box-container');
|
||||
const products = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
// Check if we have products for the first step
|
||||
const hasProducts = products.length > 0;
|
||||
|
||||
// Check if we have a selected box for the second step
|
||||
const hasBoxSelected = boxContainers.length > 0;
|
||||
|
||||
// Determine which page we're on to show appropriate button text
|
||||
const isCustomizePage = pathname.includes('/customize');
|
||||
const nextStepUrl = "/build-box/customize";
|
||||
const nextStepText = "Idući korak";
|
||||
|
||||
// Determine if the next step button should be disabled
|
||||
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
|
||||
|
||||
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string, color: string | undefined) => {
|
||||
dispatch(removeFromBox({ id, color }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Fixed header - adjusted padding to align with main page title */}
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">Your box</Heading>
|
||||
</div>
|
||||
|
||||
{/* Scrollable products area */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>Your box is empty</Text>
|
||||
<Text className="text-sm mt-2">Add products to create your box</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Show box container if any */}
|
||||
{boxContainers.length > 0 && (
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<Text className="font-medium mb-2">Box Design</Text>
|
||||
{boxContainers.map((box: BoxItem) => (
|
||||
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">{box.name}</div>
|
||||
<div className="text-sm text-gray-500">${box.price}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(box.id, box.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove box"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product items */}
|
||||
{products.length > 0 && (
|
||||
<>
|
||||
{products.map((item: BoxItem) => (
|
||||
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-between min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<div className="max-w-[75%]">
|
||||
<Text className="text-base font-medium break-words">{item.name}</Text>
|
||||
<div className="flex items-center mt-1">
|
||||
<Text className="text-sm text-gray-500">${item.price}</Text>
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full ml-2"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-label="Color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id, item.color)}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
|
||||
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
|
||||
disabled={item.quantity <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
|
||||
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed price and button at bottom */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<Text className="font-medium">Cijena boxa</Text>
|
||||
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
|
||||
{isCustomizePage ? (
|
||||
<AddBoxToCartClient />
|
||||
) : (
|
||||
<Link
|
||||
href={isNextStepDisabled ? "#" : nextStepUrl}
|
||||
className={isNextStepDisabled ? "pointer-events-none" : ""}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
{nextStepText}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isCustomizePage && !hasProducts && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
Add at least one product to proceed
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isCustomizePage && !hasBoxSelected && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
Select a box design to continue
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
components/build-box/ClientSidebarWrapper.tsx
Normal file
49
components/build-box/ClientSidebarWrapper.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BuildBoxSidebar } from "./BuildBoxSidebar";
|
||||
|
||||
export function ClientSidebarWrapper() {
|
||||
// Use state to prevent hydration errors
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// After component mounts, set isClient to true
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// On the server, or during hydration, return a placeholder with the same dimensions
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Section spacing="xs" className="border-b">
|
||||
<Heading level={3} className="text-center">Your box</Heading>
|
||||
</Section>
|
||||
<div className="flex-1 p-4">
|
||||
{/* Placeholder content to match dimensions */}
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="font-medium">Cijena boxa</p>
|
||||
<p className="font-medium">$0.00</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On the client, after hydration, render the actual sidebar
|
||||
return <BuildBoxSidebar />;
|
||||
}
|
||||
24
components/build-box/RedirectIfEmptyBox.tsx
Normal file
24
components/build-box/RedirectIfEmptyBox.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function RedirectIfEmptyBox() {
|
||||
const router = useRouter();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMounted && items.length === 0) {
|
||||
router.replace('/build-box');
|
||||
}
|
||||
}, [items, router, isMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { clearBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import { addItem } from "components/cart/actions";
|
||||
import { useCart } from "components/cart/cart-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AddBoxToCartClientWithTranslationProps {
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function AddBoxToCartClientWithTranslation({ buttonText }: AddBoxToCartClientWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const boxItems = useAppSelector(selectBoxItems);
|
||||
const router = useRouter();
|
||||
const { addCartItem } = useCart();
|
||||
|
||||
// Check if we have a box container
|
||||
const hasBoxContainer = boxItems.some(item => item.variantId === 'box-container');
|
||||
|
||||
// Disable the button if no box container is selected
|
||||
const isDisabled = !hasBoxContainer;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get box container details (for image, name etc.)
|
||||
const boxContainer = boxItems.find(item => item.variantId === 'box-container');
|
||||
|
||||
if (!boxContainer) {
|
||||
throw new Error('No box container selected');
|
||||
}
|
||||
|
||||
// Generate a unique box ID to group all items together
|
||||
const boxGroupId = `box-${Date.now()}`;
|
||||
|
||||
// Add box to cart with attribute marking it as a box container
|
||||
await addItem(
|
||||
null,
|
||||
boxContainer.id,
|
||||
1,
|
||||
[
|
||||
{ key: "_box_type", value: "container" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Add all product items with same box group ID
|
||||
for (const item of boxItems.filter(i => i.variantId !== 'box-container')) {
|
||||
await addItem(
|
||||
null,
|
||||
item.id,
|
||||
item.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "item" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the box
|
||||
dispatch(clearBox());
|
||||
|
||||
// Redirect to cart page
|
||||
router.push('/cart');
|
||||
} catch (error) {
|
||||
console.error('Error adding box to cart:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddToCart}
|
||||
className="w-full py-3"
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{isLoading ? 'Loading...' : (buttonText || t('buildBox.customize.addToCart'))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface BoxOption {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface BuildBoxCustomizeClientWithTranslationProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizeClientWithTranslation({ boxes }: BuildBoxCustomizeClientWithTranslationProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedBox, setSelectedBox] = useState<string | null>(null);
|
||||
const boxItems = useAppSelector(selectBoxItems);
|
||||
|
||||
// Učitaj odabranu kutiju iz postojećih item-a prilikom učitavanja stranice
|
||||
useEffect(() => {
|
||||
const existingBox = boxItems.find(item => item.variantId === 'box-container');
|
||||
if (existingBox) {
|
||||
setSelectedBox(existingBox.id);
|
||||
}
|
||||
}, [boxItems]);
|
||||
|
||||
const handleSelectBox = (box: BoxOption) => {
|
||||
// Ako je ista kutija već odabrana, ne radi ništa
|
||||
if (selectedBox === box.id) return;
|
||||
|
||||
setSelectedBox(box.id);
|
||||
|
||||
// Ukloni sve postojeće kutije iz košarice
|
||||
const existingBoxes = boxItems.filter(item => item.variantId === 'box-container');
|
||||
existingBoxes.forEach(existingBox => {
|
||||
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
|
||||
});
|
||||
|
||||
// Add box to the cart
|
||||
dispatch(addToBox({
|
||||
id: box.id,
|
||||
name: box.title,
|
||||
price: box.price,
|
||||
image: box.image,
|
||||
quantity: 1,
|
||||
variantId: 'box-container', // Special ID to identify this as a box
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{boxes.map((box) => {
|
||||
const isSelected = selectedBox === box.id;
|
||||
return (
|
||||
<div
|
||||
key={box.id}
|
||||
className={`flex flex-col group relative cursor-pointer ${isSelected ? 'ring-2 ring-primary rounded-lg' : ''}`}
|
||||
onClick={() => handleSelectBox(box)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{/* Oznaka za odabranu kutiju */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 right-0 bg-primary text-white m-2 rounded-full p-1 w-6 h-6 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile button - always visible */}
|
||||
<div className="absolute inset-0 flex items-center justify-center md:hidden">
|
||||
<Button
|
||||
variant={isSelected ? "custom" : "primary"}
|
||||
className="w-auto shadow-md"
|
||||
>
|
||||
{isSelected
|
||||
? (locale === 'en' ? 'Selected' : 'Odabrano')
|
||||
: t('buildBox.customize.options.select')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop button - visible on hover */}
|
||||
<div className="absolute inset-0 hidden md:flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<Button
|
||||
variant={isSelected ? "custom" : "primary"}
|
||||
className="w-auto shadow-md"
|
||||
>
|
||||
{isSelected
|
||||
? (locale === 'en' ? 'Selected' : 'Odabrano')
|
||||
: t('buildBox.customize.options.select')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col">
|
||||
<h3 className="text-lg font-bold">{box.title}</h3>
|
||||
<p className="text-lg font-medium">${box.price.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal file
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import Link from "next/link";
|
||||
import { BoxOption } from "../BuildBoxCustomizeClient";
|
||||
import { RedirectIfEmptyBox } from "../RedirectIfEmptyBox";
|
||||
import { BuildBoxCustomizeClientWithTranslation } from "./BuildBoxCustomizeClientWithTranslation";
|
||||
import { BuildBoxLayout } from "./BuildBoxLayout";
|
||||
|
||||
interface BuildBoxCustomizePageContentProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizePageContent({ boxes }: BuildBoxCustomizePageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Back button component
|
||||
const BackButton = (
|
||||
<Link href="/build-box" className="inline-flex items-center text-sm text-gray-600">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{t('buildBox.customize.back')}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// If no boxes, display a message
|
||||
if (!boxes || boxes.length === 0) {
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
|
||||
<div className="text-center py-8">
|
||||
<p>{t('buildBox.customize.emptyMessage')}</p>
|
||||
</div>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
|
||||
{/* Client component to check if box has items and redirect if empty */}
|
||||
<RedirectIfEmptyBox />
|
||||
|
||||
{/* Boxes Grid - Client Component */}
|
||||
<BuildBoxCustomizeClientWithTranslation boxes={boxes} />
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
113
components/build-box/client/BuildBoxLayout.tsx
Normal file
113
components/build-box/client/BuildBoxLayout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import { container } from "@/lib/utils";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ClientSidebarWrapperWithTranslation } from "./ClientSidebarWrapperWithTranslation";
|
||||
|
||||
interface BuildBoxLayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
step: number;
|
||||
totalSteps?: number;
|
||||
backButton?: ReactNode;
|
||||
}
|
||||
|
||||
export function BuildBoxLayout({
|
||||
children,
|
||||
title,
|
||||
step,
|
||||
totalSteps = 2,
|
||||
backButton
|
||||
}: BuildBoxLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mobileCartOpen, setMobileCartOpen] = useState(false);
|
||||
const storeBoxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
// Dodajemo state za boxTotal koji će se koristiti za prikaz
|
||||
const [displayBoxTotal, setDisplayBoxTotal] = useState(0);
|
||||
|
||||
// Ažuriramo displayBoxTotal samo na klijentskoj strani
|
||||
useEffect(() => {
|
||||
setDisplayBoxTotal(storeBoxTotal);
|
||||
}, [storeBoxTotal]);
|
||||
|
||||
// Toggle mobile cart sidebar
|
||||
const toggleMobileCart = () => {
|
||||
setMobileCartOpen(!mobileCartOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Main content */}
|
||||
<div className={container}>
|
||||
{/* Custom header with step indicator */}
|
||||
<div className="mt-8 mb-8">
|
||||
{/* Za korak 2, gumb nazad je iznad naslova */}
|
||||
{step === 2 && backButton && (
|
||||
<div className="mb-4">
|
||||
{backButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Za korak 1, gumb nazad je pored naslova (ako postoji) */}
|
||||
{step === 1 && backButton && (
|
||||
<div>{backButton}</div>
|
||||
)}
|
||||
|
||||
<Heading level={step === 1 ? 2 : 1} className={step === 1 ? "mt-16" : ""}>
|
||||
{title}:
|
||||
<span className="text-primary">{t('buildBox.step')} {step}</span>
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pr-0 lg:pr-[240px]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Mobile sticky footer with cart button */}
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-[#E8E8E8] p-4 z-20 shadow-sm">
|
||||
<div
|
||||
className="w-full py-3 flex items-center justify-between cursor-pointer"
|
||||
onClick={toggleMobileCart}
|
||||
>
|
||||
<span className="font-medium">{t('buildBox.sidebar.title')}</span>
|
||||
<ChevronUp size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add padding at the bottom on mobile to account for the sticky footer */}
|
||||
<div className="h-20 lg:h-0"></div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - right side, fixed position */}
|
||||
<div className="hidden lg:block fixed top-[63px] right-0 w-[230px] h-[calc(100vh-65px)] bg-gray-100 border-l border-gray-200 shadow-sm z-10">
|
||||
<ClientSidebarWrapperWithTranslation />
|
||||
</div>
|
||||
|
||||
{/* Mobile full-screen sidebar overlay */}
|
||||
{mobileCartOpen && (
|
||||
<div className="lg:hidden fixed inset-0 bg-white z-50 flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Heading level={3}>{t('buildBox.sidebar.title')}</Heading>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={toggleMobileCart}
|
||||
>
|
||||
<ChevronDown size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ClientSidebarWrapperWithTranslation isMobile={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/build-box/client/BuildBoxPageContent.tsx
Normal file
71
components/build-box/client/BuildBoxPageContent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { SortDropdown } from "@/components/products/client/SortDropdown";
|
||||
import { FilterSidebar } from "@/components/products/FilterSidebar";
|
||||
import { useProductFilters } from "@/components/products/hooks/useProductFilters";
|
||||
import { FilterButton } from "@/components/products/ProductComponents/FilterButton";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { ProductGrid } from "../../products/ProductGrid";
|
||||
import { BuildBoxLayout } from "./BuildBoxLayout";
|
||||
|
||||
interface BuildBoxPageContentProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function BuildBoxPageContent({ products }: BuildBoxPageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
// If no products, display a message
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.title')} step={1}>
|
||||
<div className="text-center py-8">
|
||||
<p>{t('buildBox.emptyMessage')}</p>
|
||||
</div>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.title')} step={1}>
|
||||
<div className="mb-8">
|
||||
{/* Filter and Sort Controls */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton onClick={() => setSidebarOpen(true)} />
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
label={t('products.sort.title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductGrid
|
||||
products={sortedProducts}
|
||||
title=""
|
||||
showControls={false}
|
||||
/>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal file
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AddBoxToCartClient } from "../AddBoxToCartClient";
|
||||
|
||||
interface BuildBoxSidebarWithTranslationProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function BuildBoxSidebarWithTranslation({ isMobile = false }: BuildBoxSidebarWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
const isEmpty = items.length === 0;
|
||||
const pathname = usePathname();
|
||||
|
||||
// Separate box containers from products
|
||||
const boxContainers = items.filter(item => item.variantId === 'box-container');
|
||||
const products = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
// Check if we have products for the first step
|
||||
const hasProducts = products.length > 0;
|
||||
|
||||
// Check if we have a selected box for the second step
|
||||
const hasBoxSelected = boxContainers.length > 0;
|
||||
|
||||
// Determine which page we're on to show appropriate button text
|
||||
const isCustomizePage = pathname.includes('/customize');
|
||||
const nextStepUrl = "/build-box/customize";
|
||||
const nextStepText = t('buildBox.sidebar.nextStep');
|
||||
|
||||
// Determine if the next step button should be disabled
|
||||
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
|
||||
|
||||
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string, color: string | undefined) => {
|
||||
dispatch(removeFromBox({ id, color }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Fixed header - prikazan samo ako nije mobilni prikaz */}
|
||||
{!isMobile && (
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable products area */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>{t('buildBox.sidebar.empty')}</Text>
|
||||
<Text className="text-sm mt-2">{t('buildBox.sidebar.emptySubtext')}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Show box container if any */}
|
||||
{boxContainers.length > 0 && (
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<Text className="font-medium mb-2">{t('buildBox.sidebar.boxDesign')}</Text>
|
||||
{boxContainers.map((box: BoxItem) => (
|
||||
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">{box.name}</div>
|
||||
<div className="text-sm text-gray-500">${box.price}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(box.id, box.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove box"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product items */}
|
||||
{products.length > 0 && (
|
||||
<>
|
||||
{products.map((item: BoxItem) => (
|
||||
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-between h-24">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">${item.price}</div>
|
||||
{item.color && (
|
||||
<div className="text-xs text-gray-500 mt-1 flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-1"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span>Color</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id, item.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
|
||||
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
|
||||
disabled={item.quantity <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
|
||||
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed price and button at bottom */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<Text className="font-medium">{t('buildBox.sidebar.boxPrice')}</Text>
|
||||
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
|
||||
{isCustomizePage ? (
|
||||
<AddBoxToCartClient />
|
||||
) : (
|
||||
<Link
|
||||
href={isNextStepDisabled ? "#" : nextStepUrl}
|
||||
className={isNextStepDisabled ? "pointer-events-none" : ""}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
{nextStepText}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isCustomizePage && !hasProducts && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
{t('buildBox.sidebar.addProductWarning')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isCustomizePage && !hasBoxSelected && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
{t('buildBox.sidebar.selectBoxWarning')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BuildBoxSidebarWithTranslation } from "./BuildBoxSidebarWithTranslation";
|
||||
|
||||
interface ClientSidebarWrapperWithTranslationProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function ClientSidebarWrapperWithTranslation({ isMobile = false }: ClientSidebarWrapperWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use state to prevent hydration errors
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// After component mounts, set isClient to true
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// On the server, or during hydration, return a placeholder with the same dimensions
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{!isMobile && (
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 p-4">
|
||||
{/* Placeholder content to match dimensions */}
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="font-medium">{t('buildBox.sidebar.boxPrice')}</p>
|
||||
<p className="font-medium">$0.00</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On the client, after hydration, render the actual sidebar
|
||||
return <BuildBoxSidebarWithTranslation isMobile={isMobile} />;
|
||||
}
|
||||
40
components/carousel.tsx
Normal file
40
components/carousel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from './grid/tile';
|
||||
|
||||
export async function Carousel() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
// Purposefully duplicating products to make the carousel loop and not run out of products on wide screens.
|
||||
const carouselProducts = [...products, ...products, ...products];
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto pb-6 pt-1">
|
||||
<ul className="flex gap-4">
|
||||
{carouselProducts.map((product, i) => (
|
||||
<li
|
||||
key={`${product.handle}${i}`}
|
||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||
>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
components/cart/CartBoxItem.tsx
Normal file
161
components/cart/CartBoxItem.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { useAppDispatch } from '@/lib/redux/hooks';
|
||||
import { loadBoxForEditing } from '@/lib/redux/slices/boxSlice';
|
||||
import Price from 'components/price';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { ChevronDown, ChevronUp, Pencil, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { CartProductItem } from './CartProductItem';
|
||||
import { getUniqueItemKey } from './processCartItems';
|
||||
|
||||
interface CartBoxItemProps {
|
||||
boxItem: CartItem;
|
||||
boxProducts: CartItem[];
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function CartBoxItem({ boxItem, boxProducts, onUpdate, isPending }: CartBoxItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get box attributes if any
|
||||
const boxGroupId = boxItem.attributes?.find(attr => attr.key === '_box_group_id')?.value || 'box';
|
||||
|
||||
// Handle box deletion - delete box and all products inside it
|
||||
const handleBoxDelete = () => {
|
||||
// First update localStorage to reflect the box deletion
|
||||
try {
|
||||
const boxStateString = localStorage.getItem('lastBoxState');
|
||||
if (boxStateString) {
|
||||
const boxState = JSON.parse(boxStateString);
|
||||
|
||||
// Check if this is the box that's currently saved for editing
|
||||
if (boxState.originalBoxGroupId === boxGroupId) {
|
||||
// Remove the box from localStorage
|
||||
localStorage.removeItem('lastBoxState');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating box state in localStorage on delete', error);
|
||||
}
|
||||
|
||||
// First delete the box container
|
||||
onUpdate(boxItem.merchandise.id, 'delete', boxItem.id);
|
||||
|
||||
// Then delete all products inside the box
|
||||
boxProducts.forEach(product => {
|
||||
if (product.id) {
|
||||
onUpdate(product.merchandise.id, 'delete', product.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle box editing
|
||||
const handleEditBox = () => {
|
||||
// Prvo pokušaj direktno spremiti trenutni boxGroupId u lokalno stanje
|
||||
try {
|
||||
const currentBoxState = localStorage.getItem('lastBoxState');
|
||||
let boxStateObject = currentBoxState ? JSON.parse(currentBoxState) : {};
|
||||
|
||||
// Dodaj originalni boxGroupId da kasnije znamo što trebamo obrisati
|
||||
boxStateObject.originalBoxGroupId = boxGroupId;
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxStateObject));
|
||||
} catch (error) {
|
||||
console.error('Failed to save original box group ID', error);
|
||||
}
|
||||
|
||||
// Attempt to load box state for editing from localStorage
|
||||
dispatch(loadBoxForEditing());
|
||||
|
||||
// Redirect to build-box page
|
||||
router.push('/build-box');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
{/* Box Header */}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center">
|
||||
{/* Box Image */}
|
||||
<div className="relative h-24 w-24 flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={boxItem.merchandise.product.featuredImage?.url || ''}
|
||||
alt={boxItem.merchandise.product.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Box Details */}
|
||||
<div className="pl-4">
|
||||
<Heading level={4}>
|
||||
{boxItem.merchandise.product.title}
|
||||
</Heading>
|
||||
<Price
|
||||
amount={boxItem.cost.totalAmount.amount}
|
||||
currencyCode={boxItem.cost.totalAmount.currencyCode}
|
||||
className="text-sm mt-1 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Actions */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
onClick={handleBoxDelete}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
|
||||
disabled={isPending}
|
||||
aria-label={t('cart.remove')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleEditBox}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('cart.editBox')}
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
|
||||
aria-label={isExpanded ? t('cart.collapseBox') : t('cart.expandBox')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Products */}
|
||||
{isExpanded && boxProducts.length > 0 && (
|
||||
<div className="space-y-4 py-2 pb-4">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">{t('cart.customBoxContents')}</h4>
|
||||
{boxProducts.map((item, index) => (
|
||||
<CartProductItem
|
||||
key={getUniqueItemKey(item, boxGroupId, index)}
|
||||
item={item}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
isInBox={true}
|
||||
boxGroupId={boxGroupId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
components/cart/CartDiscountForm.tsx
Normal file
84
components/cart/CartDiscountForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { FormEvent, useState, useTransition } from 'react';
|
||||
import { validateDiscountCode } from './actions';
|
||||
|
||||
export function CartDiscountForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [couponMessage, setCouponMessage] = useState<{ text: string; isValid: boolean } | null>(null);
|
||||
|
||||
// Handle coupon validation
|
||||
const handleCouponSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!couponCode.trim()) {
|
||||
setCouponMessage({
|
||||
text: 'Please enter a discount code',
|
||||
isValid: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCouponMessage(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await validateDiscountCode({}, couponCode);
|
||||
setCouponMessage({
|
||||
text: result.message,
|
||||
isValid: result.isValid
|
||||
});
|
||||
} catch (error) {
|
||||
setCouponMessage({
|
||||
text: 'Error validating coupon code',
|
||||
isValid: false
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Label className="mb-2">{t('cart.discountCode')}</Label>
|
||||
<form onSubmit={handleCouponSubmit} className="space-y-2">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 border rounded-l-md p-3"
|
||||
placeholder={t('cart.discountCodePlaceholder')}
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="rounded-l-none"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Loading...' : t('cart.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Coupon feedback message */}
|
||||
{couponMessage && (
|
||||
<div className={`flex items-center ${couponMessage.isValid ? 'text-green-600' : 'text-red-600'} mt-2`}>
|
||||
{couponMessage.isValid ?
|
||||
<CheckCircle size={16} className="mr-1" /> :
|
||||
<AlertCircle size={16} className="mr-1" />
|
||||
}
|
||||
<Text size="sm" as="span" className={couponMessage.isValid ? 'text-green-600' : 'text-red-600'}>
|
||||
{couponMessage.text}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
components/cart/CartLink.tsx
Normal file
23
components/cart/CartLink.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CartLink() {
|
||||
const { cart } = useCart();
|
||||
const itemCount = cart?.totalQuantity || 0;
|
||||
|
||||
return (
|
||||
<Link href="/cart" className="group -m-2 flex items-center p-2">
|
||||
<ShoppingCart
|
||||
className="h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-2 text-sm font-medium text-gray-700 group-hover:text-gray-800">
|
||||
{itemCount}
|
||||
</span>
|
||||
<span className="sr-only">items in cart, view cart</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
180
components/cart/CartPage.tsx
Normal file
180
components/cart/CartPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { container } from '@/lib/utils';
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import { useTransition } from 'react';
|
||||
import { removeItem, updateItemQuantity } from './actions';
|
||||
import { CartDiscountForm } from './CartDiscountForm';
|
||||
import { CartSummary } from './CartSummary';
|
||||
import { EmptyCartMessage } from './EmptyCartMessage';
|
||||
import { useCartProcessing } from './hooks/useCartProcessing';
|
||||
import { BoxesSection } from './sections/BoxesSection';
|
||||
import { CartHeader } from './sections/CartHeader';
|
||||
import { CartLoading } from './sections/CartLoading';
|
||||
import { OrderNotes } from './sections/OrderNotes';
|
||||
import { ProductsSection } from './sections/ProductsSection';
|
||||
|
||||
export default function CartPage() {
|
||||
const { cart, updateCartItem } = useCart();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { boxes, standaloneProducts, isGroupingComplete, didInitialProcess } = useCartProcessing(cart);
|
||||
|
||||
if (!cart?.lines.length) {
|
||||
return <EmptyCartMessage />;
|
||||
}
|
||||
|
||||
// Show loading state while processing cart items
|
||||
if (!didInitialProcess || !isGroupingComplete) {
|
||||
return <CartLoading />;
|
||||
}
|
||||
|
||||
const handleUpdateCartItem = (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => {
|
||||
// First update the client-side cart state for immediate feedback
|
||||
updateCartItem(merchandiseId, updateType, itemId);
|
||||
|
||||
// Update lastBoxState in localStorage if this is a box item
|
||||
try {
|
||||
const boxStateString = localStorage.getItem('lastBoxState');
|
||||
if (boxStateString) {
|
||||
const boxState = JSON.parse(boxStateString);
|
||||
|
||||
// Check if this item belongs to a box
|
||||
const cartItem = cart.lines.find(item => {
|
||||
if (itemId && item.id) {
|
||||
return item.id === itemId;
|
||||
}
|
||||
return item.merchandise.id === merchandiseId;
|
||||
});
|
||||
|
||||
if (cartItem) {
|
||||
// Check if it's a box item by looking at attributes
|
||||
const attrs = cartItem.attributes || [];
|
||||
const boxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
if (boxType && boxGroupId) {
|
||||
// Find the corresponding item in the stored state
|
||||
if (boxType === 'item' && boxState.productItems) {
|
||||
if (updateType === 'delete') {
|
||||
// Remove the item from productItems
|
||||
boxState.productItems = boxState.productItems.filter((item: { id: string, variantId: string }) =>
|
||||
item.id !== cartItem.merchandise.product.id ||
|
||||
item.variantId !== cartItem.merchandise.id
|
||||
);
|
||||
} else {
|
||||
// Update quantity
|
||||
const productItem = boxState.productItems.find((item: { id: string, variantId: string }) =>
|
||||
item.id === cartItem.merchandise.product.id &&
|
||||
item.variantId === cartItem.merchandise.id
|
||||
);
|
||||
|
||||
if (productItem) {
|
||||
const newQuantity = updateType === 'plus'
|
||||
? cartItem.quantity + 1
|
||||
: Math.max(1, cartItem.quantity - 1);
|
||||
productItem.quantity = newQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated state back to localStorage
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
}
|
||||
else if (boxType === 'container' && boxState.boxItems) {
|
||||
if (updateType === 'delete') {
|
||||
// Remove the box
|
||||
boxState.boxItems = boxState.boxItems.filter((item: { id: string }) =>
|
||||
item.id !== cartItem.merchandise.product.id
|
||||
);
|
||||
} else {
|
||||
// Update quantity
|
||||
const boxItem = boxState.boxItems.find((item: { id: string }) =>
|
||||
item.id === cartItem.merchandise.product.id
|
||||
);
|
||||
|
||||
if (boxItem) {
|
||||
const newQuantity = updateType === 'plus'
|
||||
? cartItem.quantity + 1
|
||||
: Math.max(1, cartItem.quantity - 1);
|
||||
boxItem.quantity = newQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated state back to localStorage
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating box state in localStorage', error);
|
||||
}
|
||||
|
||||
// Then update the server-side cart
|
||||
startTransition(() => {
|
||||
if (updateType === 'delete') {
|
||||
// Call server action to remove item
|
||||
removeItem({}, merchandiseId, itemId);
|
||||
} else {
|
||||
// Find the specific item to update, using both merchandise ID and item ID if provided
|
||||
let item = cart.lines.find((item: { id?: string, merchandise: { id: string } }) => {
|
||||
if (itemId && item.id) {
|
||||
// If we have item ID, use it for more specific matching
|
||||
return item.id === itemId;
|
||||
}
|
||||
// Fall back to merchandise ID only
|
||||
return item.merchandise.id === merchandiseId;
|
||||
});
|
||||
|
||||
if (item) {
|
||||
// Calculate new quantity based on the updateType
|
||||
const newQuantity = updateType === 'plus'
|
||||
? item.quantity + 1
|
||||
: Math.max(1, item.quantity - 1);
|
||||
|
||||
// Call server action to update quantity
|
||||
updateItemQuantity({}, { merchandiseId, quantity: newQuantity, itemId });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
<div className="pb-20">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between gap-8">
|
||||
{/* Left Side: Cart Items */}
|
||||
<div className="lg:w-[62%]">
|
||||
<CartHeader totalQuantity={cart.totalQuantity} />
|
||||
|
||||
{/* Boxes Section */}
|
||||
<BoxesSection
|
||||
boxes={boxes}
|
||||
onUpdate={handleUpdateCartItem}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
{/* Standalone Products Section */}
|
||||
<ProductsSection
|
||||
products={standaloneProducts}
|
||||
onUpdate={handleUpdateCartItem}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
{/* Order Notes */}
|
||||
<OrderNotes />
|
||||
|
||||
{/* Discount Form */}
|
||||
<CartDiscountForm />
|
||||
</div>
|
||||
|
||||
{/* Right Side: Order Summary */}
|
||||
<div className="lg:w-[30%]">
|
||||
<div className="mt-6 lg:mt-[72px]">
|
||||
<CartSummary cart={cart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
components/cart/CartProductItem.tsx
Normal file
142
components/cart/CartProductItem.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { colorHexMap } from '@/components/products/utils/colorUtils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import Price from 'components/price';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { QuantityControls } from './QuantityControls';
|
||||
|
||||
interface CartProductItemProps {
|
||||
item: CartItem;
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
isInBox?: boolean;
|
||||
boxGroupId?: string;
|
||||
}
|
||||
|
||||
export function CartProductItem({ item, onUpdate, isPending, isInBox = false, boxGroupId }: CartProductItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { cart } = useCart();
|
||||
|
||||
// Check if this item has a color option
|
||||
const colorOption = item.merchandise.selectedOptions.find(option =>
|
||||
option.name.toLowerCase() === 'color' ||
|
||||
option.name.toLowerCase() === 'colour' ||
|
||||
option.name.toLowerCase() === 'boja'
|
||||
);
|
||||
|
||||
// Get the color hex code from the color name if it exists
|
||||
const colorHex = colorOption
|
||||
? colorHexMap[colorOption.value.toLowerCase()] || colorOption.value
|
||||
: null;
|
||||
|
||||
// Get the unique ID of this cart item
|
||||
const itemId = item.id;
|
||||
|
||||
// Handle quantity changes
|
||||
const handleIncrease = () => onUpdate(item.merchandise.id, 'plus', itemId);
|
||||
const handleDecrease = () => onUpdate(item.merchandise.id, 'minus', itemId);
|
||||
|
||||
// Enhanced delete handler
|
||||
const handleDelete = () => {
|
||||
// Basic delete operation for this item
|
||||
onUpdate(item.merchandise.id, 'delete', itemId);
|
||||
|
||||
// Special handling for box items
|
||||
if (isInBox && boxGroupId && cart) {
|
||||
// Find all items in this box group
|
||||
const boxItems = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
return itemBoxGroupId === boxGroupId && itemBoxType === 'item' && line.id !== itemId;
|
||||
});
|
||||
|
||||
// If this is the last item (only 1 left - the one we're deleting), also delete the box container
|
||||
if (boxItems.length === 0) {
|
||||
// Find the box container
|
||||
const boxContainer = cart.lines.find(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
|
||||
});
|
||||
|
||||
// Delete the box container too
|
||||
if (boxContainer && boxContainer.id) {
|
||||
onUpdate(boxContainer.merchandise.id, 'delete', boxContainer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap md:flex-nowrap items-start ${!isInBox ? 'border-b pb-6' : 'pb-4'}`}>
|
||||
{/* Product Image */}
|
||||
<div className="relative h-20 w-20 flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={item.merchandise.product.featuredImage?.url || ''}
|
||||
alt={item.merchandise.product.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 pl-4 min-w-0">
|
||||
<div className="flex flex-col">
|
||||
<Text weight={isInBox ? 'regular' : 'semibold'} className="pr-2 break-words">
|
||||
{item.merchandise.product.title}
|
||||
</Text>
|
||||
|
||||
{/* Show color indicator if color is available */}
|
||||
{colorHex && (
|
||||
<div className="flex items-center mt-1">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
aria-label={`${colorOption?.value || 'Unknown'}`}
|
||||
/>
|
||||
<Text size="xs" color="muted" as="span" className="ml-2">
|
||||
{colorOption?.value || ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Price
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
className="text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center ml-auto mt-2 md:mt-0">
|
||||
<div className="mr-4">
|
||||
<QuantityControls
|
||||
quantity={item.quantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
isDisabled={isPending}
|
||||
minQuantity={1}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
|
||||
disabled={isPending}
|
||||
aria-label={t('cart.remove')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/cart/CartSummary.tsx
Normal file
64
components/cart/CartSummary.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import Price from 'components/price';
|
||||
import { Cart } from 'lib/shopify/types';
|
||||
import { useState } from 'react';
|
||||
import { redirectToCheckout } from './actions';
|
||||
|
||||
interface CartSummaryProps {
|
||||
cart: Cart;
|
||||
}
|
||||
|
||||
export function CartSummary({ cart }: CartSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setIsLoading(true);
|
||||
await redirectToCheckout();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-6 bg-white sticky top-20">
|
||||
<Heading level={4} className="mb-6">{t('cart.orderSummary')}</Heading>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.subtotal')}</Text>
|
||||
<Price
|
||||
amount={cart.cost.subtotalAmount.amount}
|
||||
currencyCode={cart.cost.subtotalAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.shipping')}</Text>
|
||||
<Text size="sm" color="muted" className="text-right">{t('cart.calculated')}</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.total')}</Text>
|
||||
<Price
|
||||
amount={cart.cost.totalAmount.amount}
|
||||
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? "Loading..." : t('cart.continueToCheckout')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/cart/EmptyCartMessage.tsx
Normal file
25
components/cart/EmptyCartMessage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Section } from '@/components/ui/Section';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
export function EmptyCartMessage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex flex-col items-center justify-center text-center p-8 max-w-md mx-auto border rounded-lg bg-gray-50">
|
||||
<Heading level={3} className="mb-3">{t('cart.emptyCart')}</Heading>
|
||||
<Text color="muted" className="mb-6">Add some items to your cart to see them here.</Text>
|
||||
<Button
|
||||
href="/products"
|
||||
variant="primary"
|
||||
>
|
||||
{t('cart.startShopping')}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
51
components/cart/QuantityControls.tsx
Normal file
51
components/cart/QuantityControls.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface QuantityControlsProps {
|
||||
quantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
isDisabled?: boolean;
|
||||
minQuantity?: number;
|
||||
}
|
||||
|
||||
export function QuantityControls({
|
||||
quantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
isDisabled = false,
|
||||
minQuantity = 1
|
||||
}: QuantityControlsProps) {
|
||||
return (
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<Button
|
||||
onClick={onDecrease}
|
||||
variant="default"
|
||||
className="px-3 py-1 border-0 rounded-none"
|
||||
disabled={isDisabled || quantity <= minQuantity}
|
||||
aria-label="Decrease quantity"
|
||||
size="sm"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</Button>
|
||||
<input
|
||||
type="text"
|
||||
value={quantity}
|
||||
readOnly
|
||||
className="w-10 text-center border-x border-gray-300"
|
||||
/>
|
||||
<Button
|
||||
onClick={onIncrease}
|
||||
variant="default"
|
||||
className="px-3 py-1 border-0 rounded-none"
|
||||
disabled={isDisabled}
|
||||
aria-label="Increase quantity"
|
||||
size="sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
components/cart/actions.ts
Normal file
251
components/cart/actions.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
'use server';
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function addItem(
|
||||
prevState: any,
|
||||
selectedVariantId: string | undefined,
|
||||
quantity: number = 1,
|
||||
attributes?: { key: string; value: string }[]
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cartId) {
|
||||
const cart = await createCart();
|
||||
cartId = cart.id!;
|
||||
cookieStore.set('cartId', cartId);
|
||||
}
|
||||
|
||||
await addToCart(cartId, [{
|
||||
merchandiseId: selectedVariantId,
|
||||
quantity,
|
||||
attributes
|
||||
}]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeItem(prevState: any, merchandiseId: string, itemId?: string) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
// Find specific line item - first by itemId if provided, otherwise by merchandiseId
|
||||
let lineItem;
|
||||
|
||||
if (itemId) {
|
||||
// If we have a specific item ID (e.g., for box items), use that first
|
||||
lineItem = cart.lines.find((line) => line.id === itemId);
|
||||
}
|
||||
|
||||
// If no item ID was provided or no match was found, fall back to merchandise ID
|
||||
if (!lineItem) {
|
||||
lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
|
||||
}
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
// Check if this is a box item being removed
|
||||
const lineAttributes = lineItem.attributes || [];
|
||||
const boxType = lineAttributes.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = lineAttributes.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
// If this is a box item (not container), check if it's the last one before removing
|
||||
if (boxType === 'item' && boxGroupId) {
|
||||
// Find all items in this box (excluding the current one we're removing)
|
||||
const remainingBoxItems = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
|
||||
return itemBoxGroupId === boxGroupId &&
|
||||
line.id !== lineItem.id &&
|
||||
itemBoxType === 'item';
|
||||
});
|
||||
|
||||
// If this is the last item, find the box container and remove both together
|
||||
if (remainingBoxItems.length === 0) {
|
||||
const boxContainer = cart.lines.find(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
|
||||
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
|
||||
});
|
||||
|
||||
// If box container exists, remove both item and container in a single operation
|
||||
if (boxContainer && boxContainer.id) {
|
||||
// Batched removal of both the item and box container
|
||||
await removeFromCart(cartId, [lineItem.id, boxContainer.id]);
|
||||
} else {
|
||||
// Just remove the item if no container found
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
} else {
|
||||
// Not the last item, just remove this one
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
} else {
|
||||
// Regular item or box container, just remove it
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
|
||||
revalidateTag(TAGS.cart);
|
||||
} else {
|
||||
return 'Item not found in cart';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Error removing item from cart';
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateItemQuantity(
|
||||
prevState: any,
|
||||
payload: {
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
itemId?: string;
|
||||
}
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
const { merchandiseId, quantity, itemId } = payload;
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
// Find the specific line item to update
|
||||
let lineItem;
|
||||
|
||||
if (itemId) {
|
||||
// First try to find by specific item ID (for items in multiple boxes)
|
||||
lineItem = cart.lines.find(line => line.id === itemId);
|
||||
}
|
||||
|
||||
// Fall back to finding by merchandise ID if item ID didn't work
|
||||
if (!lineItem) {
|
||||
lineItem = cart.lines.find(line => line.merchandise.id === merchandiseId);
|
||||
}
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
if (quantity === 0) {
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
} else {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineItem.id,
|
||||
merchandiseId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
}
|
||||
} else if (quantity > 0) {
|
||||
// If the item doesn't exist in the cart and quantity > 0, add it
|
||||
await addToCart(cartId, [{ merchandiseId, quantity }]);
|
||||
}
|
||||
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return 'Error updating item quantity';
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToCheckout() {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'No cart found';
|
||||
}
|
||||
|
||||
let cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
if (!cart.checkoutUrl) {
|
||||
return 'No checkout URL available';
|
||||
}
|
||||
|
||||
redirect(cart.checkoutUrl);
|
||||
}
|
||||
|
||||
export async function createCartAndSetCookie() {
|
||||
const cookieStore = await cookies();
|
||||
let cart = await createCart();
|
||||
cookieStore.set('cartId', cart.id!);
|
||||
}
|
||||
|
||||
export async function validateDiscountCode(prevState: any, discountCode: string) {
|
||||
if (!discountCode || discountCode.trim() === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'Please enter a discount code'
|
||||
};
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'No cart found. Please add items to your cart first.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement discount code validation
|
||||
const isValid = Math.random() > 0.5;
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
isValid: true,
|
||||
message: 'Discount code applied successfully!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'Invalid discount code. Please try again.'
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error validating discount code:', e);
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'An error occurred while validating the discount code.'
|
||||
};
|
||||
}
|
||||
}
|
||||
115
components/cart/add-to-cart.tsx
Normal file
115
components/cart/add-to-cart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useProduct } from 'components/product/product-context';
|
||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useCart } from './cart-context';
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
selectedVariantId,
|
||||
isLoading
|
||||
}: {
|
||||
availableForSale: boolean;
|
||||
selectedVariantId: string | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
Out Of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Please select an option"
|
||||
disabled
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
Please Select Options
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Add to cart"
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add to Cart'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddToCart({ product, quantity = 1 }: { product: Product; quantity?: number }) {
|
||||
const { variants, availableForSale } = product;
|
||||
const { addCartItem } = useCart();
|
||||
const { state } = useProduct();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
||||
);
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Don't proceed if the form is already being processed
|
||||
if (isLoading) return;
|
||||
|
||||
// Don't proceed if there's no variant ID
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
// Set loading state
|
||||
setIsLoading(true);
|
||||
|
||||
// Use the quantity prop passed to the component
|
||||
// No need to get it from formData since there's no quantity input in the form
|
||||
|
||||
const finalVariant = variants.find((v) => v.id === selectedVariantId);
|
||||
if (!finalVariant) return;
|
||||
|
||||
// Call the server action to add the item to the cart
|
||||
// @ts-ignore - We know our server action accepts quantity
|
||||
addItem(null, selectedVariantId, quantity)
|
||||
.then(() => {
|
||||
// Add to context cart for immediate UI feedback
|
||||
addCartItem(finalVariant, product, quantity);
|
||||
});
|
||||
|
||||
// Add a small delay before removing loading state for better UX
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
selectedVariantId={selectedVariantId}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
217
components/cart/cart-context.tsx
Normal file
217
components/cart/cart-context.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
|
||||
import React, { createContext, use, useContext, useEffect, useMemo, useOptimistic, useTransition } from 'react';
|
||||
|
||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||
|
||||
type CartAction =
|
||||
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType; itemId?: string } }
|
||||
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product, quantity: number } };
|
||||
|
||||
type CartContextType = {
|
||||
cart: Cart | undefined;
|
||||
updateCartItem: (merchandiseId: string, updateType: UpdateType, itemId?: string) => void;
|
||||
addCartItem: (variant: ProductVariant, product: Product, quantity?: number) => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
function calculateItemCost(quantity: number, price: string): string {
|
||||
return (Number(price) * quantity).toString();
|
||||
}
|
||||
|
||||
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
|
||||
if (updateType === 'delete') return null;
|
||||
|
||||
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
|
||||
if (newQuantity === 0) return null;
|
||||
|
||||
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||
const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
|
||||
|
||||
return {
|
||||
...item,
|
||||
quantity: newQuantity,
|
||||
cost: {
|
||||
...item.cost,
|
||||
totalAmount: {
|
||||
...item.cost.totalAmount,
|
||||
amount: newTotalAmount
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createOrUpdateCartItem(
|
||||
existingItem: CartItem | undefined,
|
||||
variant: ProductVariant,
|
||||
product: Product,
|
||||
quantity: number = 1
|
||||
): CartItem {
|
||||
const newQuantity = existingItem ? existingItem.quantity + quantity : quantity;
|
||||
const totalAmount = calculateItemCost(newQuantity, variant.price.amount);
|
||||
|
||||
return {
|
||||
id: existingItem?.id,
|
||||
quantity: newQuantity,
|
||||
attributes: existingItem?.attributes || [],
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: totalAmount,
|
||||
currencyCode: variant.price.currencyCode
|
||||
}
|
||||
},
|
||||
merchandise: {
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
selectedOptions: variant.selectedOptions,
|
||||
product: {
|
||||
id: product.id,
|
||||
handle: product.handle,
|
||||
title: product.title,
|
||||
featuredImage: product.featuredImage
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
|
||||
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
|
||||
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
|
||||
|
||||
return {
|
||||
totalQuantity,
|
||||
cost: {
|
||||
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalTaxAmount: { amount: '0', currencyCode }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCart(): Cart {
|
||||
return {
|
||||
id: undefined,
|
||||
checkoutUrl: '',
|
||||
totalQuantity: 0,
|
||||
lines: [],
|
||||
cost: {
|
||||
subtotalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
const currentCart = state || createEmptyCart();
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_ITEM': {
|
||||
const { merchandiseId, updateType, itemId } = action.payload;
|
||||
const updatedLines = currentCart.lines
|
||||
.map((item) => {
|
||||
// If itemId is provided, only update the specific item
|
||||
if (itemId && item.id !== itemId) {
|
||||
return item;
|
||||
}
|
||||
// Otherwise, update by merchandise ID
|
||||
if (!itemId && item.merchandise.id !== merchandiseId) {
|
||||
return item;
|
||||
}
|
||||
// Update the matching item
|
||||
return updateCartItem(item, updateType);
|
||||
})
|
||||
.filter(Boolean) as CartItem[];
|
||||
|
||||
if (updatedLines.length === 0) {
|
||||
return {
|
||||
...currentCart,
|
||||
lines: [],
|
||||
totalQuantity: 0,
|
||||
cost: {
|
||||
...currentCart.cost,
|
||||
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
|
||||
}
|
||||
case 'ADD_ITEM': {
|
||||
const { variant, product, quantity = 1 } = action.payload;
|
||||
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
|
||||
const updatedItem = createOrUpdateCartItem(existingItem, variant, product, quantity);
|
||||
|
||||
const updatedLines = existingItem
|
||||
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
|
||||
: [...currentCart.lines, updatedItem];
|
||||
|
||||
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
|
||||
}
|
||||
default:
|
||||
return currentCart;
|
||||
}
|
||||
}
|
||||
|
||||
export function CartProvider({
|
||||
children,
|
||||
cartPromise
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
}) {
|
||||
const initialCart = use(cartPromise);
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// More detailed debugging for cart loading
|
||||
useEffect(() => {
|
||||
if (initialCart?.lines?.length) {
|
||||
// Detailed inspection of the cart items and their attributes
|
||||
const itemsWithAttributes = initialCart.lines.filter(item =>
|
||||
item.attributes && item.attributes.length > 0
|
||||
);
|
||||
|
||||
// Log each item with its attributes for debugging
|
||||
initialCart.lines.forEach((item, index) => {
|
||||
const attrs = item.attributes || [];
|
||||
const boxType = attrs.find(a => a.key === 'box_type')?.value;
|
||||
const boxGroupId = attrs.find(a => a.key === 'box_group_id')?.value;
|
||||
});
|
||||
}
|
||||
}, [initialCart]);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType, itemId?: string) => {
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType, itemId } });
|
||||
});
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product, quantity: number = 1) => {
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product, quantity } });
|
||||
});
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
cart: optimisticCart,
|
||||
updateCartItem,
|
||||
addCartItem
|
||||
}),
|
||||
[optimisticCart]
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCart must be used within a CartProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
100
components/cart/hooks/useCartProcessing.ts
Normal file
100
components/cart/hooks/useCartProcessing.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Cart } from 'lib/shopify/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useCartProcessing(cart: Cart | undefined) {
|
||||
const [boxGroups, setBoxGroups] = useState(new Map<string, { box: any; products: any[] }>());
|
||||
const [standaloneProducts, setStandaloneProducts] = useState<any[]>([]);
|
||||
const [isGroupingComplete, setIsGroupingComplete] = useState(false);
|
||||
const [didInitialProcess, setDidInitialProcess] = useState(false);
|
||||
|
||||
// First-time cart load processing with delay
|
||||
useEffect(() => {
|
||||
if (!cart?.lines?.length) {
|
||||
setIsGroupingComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a small timeout to ensure cart data is fully loaded
|
||||
const timeoutId = setTimeout(() => {
|
||||
processCartItems();
|
||||
}, 100); // 100ms delay
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// Process cart items whenever the cart changes after initial load
|
||||
useEffect(() => {
|
||||
if (!didInitialProcess && cart?.lines?.length) {
|
||||
// Skip first update - handled by the first effect
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cart?.lines?.length) {
|
||||
setIsGroupingComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
processCartItems();
|
||||
}, [cart, didInitialProcess]);
|
||||
|
||||
// Process cart items function
|
||||
const processCartItems = () => {
|
||||
if (!cart) return;
|
||||
|
||||
// Set grouping as incomplete at the start
|
||||
setIsGroupingComplete(false);
|
||||
|
||||
const newBoxGroups = new Map<string, { box: any; products: any[] }>();
|
||||
const newStandaloneProducts: any[] = [];
|
||||
|
||||
// First, sort all items into boxes or standalone products
|
||||
cart.lines.forEach(item => {
|
||||
// Skip items without attributes (avoid runtime errors)
|
||||
if (!item.attributes || item.attributes.length === 0) {
|
||||
newStandaloneProducts.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const boxType = item.attributes.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = item.attributes.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
if (boxType === 'container' && boxGroupId) {
|
||||
// This is a box container
|
||||
if (!newBoxGroups.has(boxGroupId)) {
|
||||
newBoxGroups.set(boxGroupId, { box: item, products: [] });
|
||||
} else {
|
||||
newBoxGroups.get(boxGroupId)!.box = item;
|
||||
}
|
||||
} else if (boxType === 'item' && boxGroupId) {
|
||||
// This is an item that belongs in a box
|
||||
if (!newBoxGroups.has(boxGroupId)) {
|
||||
newBoxGroups.set(boxGroupId, { box: null, products: [item] });
|
||||
} else {
|
||||
newBoxGroups.get(boxGroupId)!.products.push(item);
|
||||
}
|
||||
} else {
|
||||
// This is a standalone product
|
||||
newStandaloneProducts.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
setBoxGroups(newBoxGroups);
|
||||
setStandaloneProducts(newStandaloneProducts);
|
||||
setDidInitialProcess(true);
|
||||
|
||||
// Mark grouping as complete
|
||||
setIsGroupingComplete(true);
|
||||
};
|
||||
|
||||
// Get boxes array from the map for rendering
|
||||
const boxes = Array.from(boxGroups.values()).filter(group => group.box);
|
||||
|
||||
return {
|
||||
boxes,
|
||||
standaloneProducts,
|
||||
isGroupingComplete,
|
||||
didInitialProcess
|
||||
};
|
||||
}
|
||||
22
components/cart/processCartItems.tsx
Normal file
22
components/cart/processCartItems.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// Function to get a unique identifier for a cart item
|
||||
export function getUniqueItemKey(item: any, boxGroupId?: string, index?: number): string {
|
||||
// Start with the merchandise id
|
||||
let key = item.merchandise.id;
|
||||
|
||||
// Add the item's own id if available
|
||||
if (item.id) {
|
||||
key = `${key}-${item.id}`;
|
||||
}
|
||||
|
||||
// Add box group id if it's part of a box
|
||||
if (boxGroupId) {
|
||||
key = `${boxGroupId}-${key}`;
|
||||
}
|
||||
|
||||
// Add index as fallback to ensure uniqueness
|
||||
if (index !== undefined) {
|
||||
key = `${key}-${index}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
34
components/cart/sections/BoxesSection.tsx
Normal file
34
components/cart/sections/BoxesSection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { CartBoxItem } from '../CartBoxItem';
|
||||
|
||||
interface BoxesSectionProps {
|
||||
boxes: Array<{ box: CartItem; products: CartItem[] }>;
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function BoxesSection({ boxes, onUpdate, isPending }: BoxesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (boxes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-4">{t('cart.boxes')}</h2>
|
||||
<div className="space-y-4">
|
||||
{boxes.map((boxGroup) => (
|
||||
<CartBoxItem
|
||||
key={boxGroup.box.id}
|
||||
boxItem={boxGroup.box}
|
||||
boxProducts={boxGroup.products}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/cart/sections/CartHeader.tsx
Normal file
20
components/cart/sections/CartHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
interface CartHeaderProps {
|
||||
totalQuantity: number;
|
||||
}
|
||||
|
||||
export function CartHeader({ totalQuantity }: CartHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="my-6 flex items-center justify-between">
|
||||
<Heading level={2}>{t('cart.title')}</Heading>
|
||||
<div className="bg-gray-100 rounded-md px-4 py-2">
|
||||
<Text>{t('cart.itemCount').replace('{count}', totalQuantity.toString())}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/cart/sections/CartLoading.tsx
Normal file
18
components/cart/sections/CartLoading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { container } from '@/lib/utils';
|
||||
|
||||
export function CartLoading() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-pulse text-center">
|
||||
<p className="text-lg">{t('cart.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/cart/sections/OrderNotes.tsx
Normal file
18
components/cart/sections/OrderNotes.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
export function OrderNotes() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<Label className="mb-2">{t('cart.notes')}</Label>
|
||||
<textarea
|
||||
className="w-full border rounded-md p-3 min-h-[100px]"
|
||||
placeholder={t('cart.notesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
components/cart/sections/ProductsSection.tsx
Normal file
34
components/cart/sections/ProductsSection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { CartProductItem } from '../CartProductItem';
|
||||
import { getUniqueItemKey } from '../processCartItems';
|
||||
|
||||
interface ProductsSectionProps {
|
||||
products: CartItem[];
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function ProductsSection({ products, onUpdate, isPending }: ProductsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-4">{t('cart.products')}</h2>
|
||||
<div className="space-y-6">
|
||||
{products.map((item, index) => (
|
||||
<CartProductItem
|
||||
key={getUniqueItemKey(item, 'standalone', index)}
|
||||
item={item}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/cookies/CookieBanner.tsx
Normal file
45
components/cookies/CookieBanner.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCookieConsent } from './CookieContext';
|
||||
|
||||
export function CookieBanner() {
|
||||
const { showBanner, acceptAll, openModal } = useCookieConsent();
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-30 bg-white shadow-lg border-t border-gray-200 px-4 py-3">
|
||||
<div className="container mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-700 text-center sm:text-left">
|
||||
Ova web stranica koristi kolačiće za poboljšanje korisničkog iskustva.
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="underline ml-1 hover:text-black"
|
||||
>
|
||||
Više informacija
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-3 whitespace-nowrap"
|
||||
onClick={openModal}
|
||||
>
|
||||
Prilagodi
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={acceptAll}
|
||||
>
|
||||
Prihvati sve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/cookies/CookieCategoryCard.tsx
Normal file
36
components/cookies/CookieCategoryCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { ToggleSwitch } from './ToggleSwitch';
|
||||
|
||||
interface CookieCategoryCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CookieCategoryCard({
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onChange,
|
||||
disabled = false
|
||||
}: CookieCategoryCardProps) {
|
||||
return (
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Heading level={4}>{title}</Heading>
|
||||
<ToggleSwitch
|
||||
enabled={enabled}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Text size="sm" className="text-gray-600">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
components/cookies/CookieContext.tsx
Normal file
160
components/cookies/CookieContext.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||
|
||||
// Define cookie categories and their default state
|
||||
export const COOKIE_CATEGORIES = {
|
||||
NECESSARY: 'necessary',
|
||||
ANALYTICS: 'analytics',
|
||||
MARKETING: 'marketing',
|
||||
PREFERENCES: 'preferences'
|
||||
} as const;
|
||||
|
||||
export type CookieCategory = typeof COOKIE_CATEGORIES[keyof typeof COOKIE_CATEGORIES];
|
||||
|
||||
export interface CookiePreferences {
|
||||
necessary: boolean; // Always true, can't be disabled
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
preferences: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: CookiePreferences = {
|
||||
necessary: true, // Always true
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
preferences: false
|
||||
};
|
||||
|
||||
const CONSENT_COOKIE_NAME = 'cookie-consent';
|
||||
const COOKIE_EXPIRY_DAYS = 365;
|
||||
|
||||
interface CookieContextType {
|
||||
preferences: CookiePreferences;
|
||||
hasConsent: boolean;
|
||||
showBanner: boolean;
|
||||
showModal: boolean;
|
||||
acceptAll: () => void;
|
||||
savePreferences: (newPrefs: CookiePreferences) => void;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
resetConsent: () => void;
|
||||
}
|
||||
|
||||
const CookieContext = createContext<CookieContextType | undefined>(undefined);
|
||||
|
||||
export function CookieProvider({ children }: { children: ReactNode }) {
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>(DEFAULT_PREFERENCES);
|
||||
const [hasConsent, setHasConsent] = useState<boolean>(false);
|
||||
const [showBanner, setShowBanner] = useState<boolean>(false);
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
|
||||
// Load stored consent preferences on mount (client-side only)
|
||||
useEffect(() => {
|
||||
const storedConsent = Cookies.get(CONSENT_COOKIE_NAME);
|
||||
|
||||
if (storedConsent) {
|
||||
try {
|
||||
const parsedPreferences = JSON.parse(storedConsent);
|
||||
setPreferences({
|
||||
...DEFAULT_PREFERENCES,
|
||||
...parsedPreferences,
|
||||
// Ensure necessary cookies are always enabled
|
||||
necessary: true
|
||||
});
|
||||
setHasConsent(true);
|
||||
setShowBanner(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse cookie consent', error);
|
||||
setShowBanner(true);
|
||||
}
|
||||
} else {
|
||||
// No stored consent, show the banner
|
||||
setShowBanner(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savePreferences = (newPrefs: CookiePreferences) => {
|
||||
const updatedPrefs = {
|
||||
...newPrefs,
|
||||
necessary: true
|
||||
};
|
||||
|
||||
// Update state
|
||||
setPreferences(updatedPrefs);
|
||||
setHasConsent(true);
|
||||
setShowBanner(false);
|
||||
setShowModal(false);
|
||||
|
||||
// Save to cookie
|
||||
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify(updatedPrefs), {
|
||||
expires: COOKIE_EXPIRY_DAYS,
|
||||
path: '/',
|
||||
sameSite: 'strict'
|
||||
});
|
||||
|
||||
applyPreferences(updatedPrefs);
|
||||
};
|
||||
|
||||
// Accept all cookies
|
||||
const acceptAll = () => {
|
||||
const allAccepted = {
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
preferences: true
|
||||
};
|
||||
|
||||
savePreferences(allAccepted);
|
||||
};
|
||||
|
||||
// Reset consent (for testing)
|
||||
const resetConsent = () => {
|
||||
Cookies.remove(CONSENT_COOKIE_NAME);
|
||||
setPreferences(DEFAULT_PREFERENCES);
|
||||
setHasConsent(false);
|
||||
setShowBanner(true);
|
||||
};
|
||||
|
||||
const openModal = () => setShowModal(true);
|
||||
const closeModal = () => setShowModal(false);
|
||||
|
||||
// Apply preferences based on consent
|
||||
const applyPreferences = (prefs: CookiePreferences) => {
|
||||
// Apply analytics preference (without console logs)
|
||||
if (prefs.analytics) {
|
||||
// Enable analytics tracking
|
||||
// Add analytics initialization code here if needed
|
||||
} else {
|
||||
// Disable analytics tracking
|
||||
// Add analytics disabling code here if needed
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
preferences,
|
||||
hasConsent,
|
||||
showBanner,
|
||||
showModal,
|
||||
acceptAll,
|
||||
savePreferences,
|
||||
openModal,
|
||||
closeModal,
|
||||
resetConsent
|
||||
};
|
||||
|
||||
return (
|
||||
<CookieContext.Provider value={value}>
|
||||
{children}
|
||||
</CookieContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCookieConsent() {
|
||||
const context = useContext(CookieContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCookieConsent must be used within a CookieProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
164
components/cookies/CookieSettingsModal.tsx
Normal file
164
components/cookies/CookieSettingsModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CookieCategoryCard } from './CookieCategoryCard';
|
||||
import { CookiePreferences, useCookieConsent } from './CookieContext';
|
||||
|
||||
// Cookie category descriptions
|
||||
const COOKIE_DESCRIPTIONS = {
|
||||
necessary: {
|
||||
title: 'Neophodni kolačići',
|
||||
description: 'Ovi kolačići su neophodni za funkcioniranje web stranice i ne mogu biti isključeni. Oni omogućuju osnovne funkcionalnosti poput navigacije stranice i pristupa sigurnim područjima.'
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analitički kolačići',
|
||||
description: 'Ovi kolačići nam pomažu razumjeti kako posjetitelji koriste našu web stranicu, prikupljajući anonimne statističke podatke. Oni nam pomažu poboljšati korisničko iskustvo i performanse stranice.'
|
||||
},
|
||||
marketing: {
|
||||
title: 'Marketinški kolačići',
|
||||
description: 'Ovi kolačići se koriste za praćenje posjetitelja na web stranicama. Namjera je prikazati oglase koji su relevantni i privlačni za pojedinog korisnika i time vrijedniji za izdavače i vanjske oglašivače.'
|
||||
},
|
||||
preferences: {
|
||||
title: 'Kolačići za personalizaciju',
|
||||
description: 'Ovi kolačići omogućuju web stranici da zapamti izbore koje ste napravili (poput korisničkog imena, jezika ili regije) i pružaju poboljšane, personalizirane značajke.'
|
||||
}
|
||||
};
|
||||
|
||||
export function CookieSettingsModal() {
|
||||
const { preferences, showModal, savePreferences, closeModal } = useCookieConsent();
|
||||
const [localPreferences, setLocalPreferences] = useState<CookiePreferences>(preferences);
|
||||
|
||||
// Sync with parent preferences when they change
|
||||
useEffect(() => {
|
||||
setLocalPreferences(preferences);
|
||||
}, [preferences]);
|
||||
|
||||
const handleToggle = (category: keyof CookiePreferences) => {
|
||||
if (category === 'necessary') return; // Necessary cookies can't be disabled
|
||||
|
||||
setLocalPreferences(prev => ({
|
||||
...prev,
|
||||
[category]: !prev[category]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
savePreferences(localPreferences);
|
||||
};
|
||||
|
||||
if (!showModal) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b flex justify-between items-center sticky top-0 bg-white z-20">
|
||||
<Heading level={3}>Postavke kolačića</Heading>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<Text className="mb-6">
|
||||
Ova web stranica koristi kolačiće za poboljšanje korisničkog iskustva. Možete prilagoditi svoje postavke
|
||||
kolačića omogućavanjem ili onemogućavanjem svake kategorije. Kolačići označeni kao "Neophodni"
|
||||
su potrebni za osnovne funkcije web stranice i ne mogu biti isključeni.
|
||||
</Text>
|
||||
|
||||
{/* Cookie categories */}
|
||||
<div className="space-y-6">
|
||||
{/* Necessary cookies - always enabled */}
|
||||
<CookieCategoryCard
|
||||
title={COOKIE_DESCRIPTIONS.necessary.title}
|
||||
description={COOKIE_DESCRIPTIONS.necessary.description}
|
||||
enabled={true}
|
||||
onChange={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
{/* Analytics cookies */}
|
||||
<CookieCategoryCard
|
||||
title={COOKIE_DESCRIPTIONS.analytics.title}
|
||||
description={COOKIE_DESCRIPTIONS.analytics.description}
|
||||
enabled={localPreferences.analytics}
|
||||
onChange={() => handleToggle('analytics')}
|
||||
/>
|
||||
|
||||
{/* Marketing cookies */}
|
||||
<CookieCategoryCard
|
||||
title={COOKIE_DESCRIPTIONS.marketing.title}
|
||||
description={COOKIE_DESCRIPTIONS.marketing.description}
|
||||
enabled={localPreferences.marketing}
|
||||
onChange={() => handleToggle('marketing')}
|
||||
/>
|
||||
|
||||
{/* Preference cookies */}
|
||||
<CookieCategoryCard
|
||||
title={COOKIE_DESCRIPTIONS.preferences.title}
|
||||
description={COOKIE_DESCRIPTIONS.preferences.description}
|
||||
enabled={localPreferences.preferences}
|
||||
onChange={() => handleToggle('preferences')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<Heading level={4} className="mb-3">O kolačićima</Heading>
|
||||
<Text className="mb-4">
|
||||
Kolačići su male tekstualne datoteke koje web stranice postavljaju na vaš uređaj prilikom posjeta.
|
||||
Koriste se za pamćenje vaših postavki, poboljšanje funkcionalnosti i prikupljanje analitičkih podataka.
|
||||
</Text>
|
||||
|
||||
<Heading level={4} className="mb-2 mt-4">Kako koristimo kolačiće</Heading>
|
||||
<Text className="mb-4">
|
||||
Koristimo različite vrste kolačića za različite svrhe. Neki su neophodni za rad web stranice,
|
||||
dok drugi nam pomažu optimizirati sadržaj i korisničko iskustvo.
|
||||
</Text>
|
||||
|
||||
<Heading level={4} className="mb-2 mt-4">Vaša prava</Heading>
|
||||
<Text className="mb-4">
|
||||
U skladu s EU regulativom o kolačićima, omogućujemo vam upravljanje postavkama kolačića.
|
||||
Više informacija možete pronaći u našim <a href="/privacy-policy" className="underline">Pravilima privatnosti</a> i
|
||||
<a href="/terms-of-service" className="underline ml-1">Uvjetima korištenja</a>.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with buttons */}
|
||||
<div className="p-4 border-t sticky bottom-0 bg-white">
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Odustani
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Spremi postavke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
components/cookies/ToggleSwitch.tsx
Normal file
27
components/cookies/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({ enabled, onChange, disabled = false }: ToggleSwitchProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'}`}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
>
|
||||
<div className={`w-10 h-6 rounded-full transition ${enabled ? 'bg-primary' : 'bg-gray-300'}`}></div>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 w-6 h-6 bg-white rounded-full border border-gray-300 transform transition ${
|
||||
enabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
components/cookies/index.tsx
Normal file
17
components/cookies/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { CookieBanner } from './CookieBanner';
|
||||
import { CookieProvider } from './CookieContext';
|
||||
import { CookieSettingsModal } from './CookieSettingsModal';
|
||||
|
||||
export function CookieConsent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CookieProvider>
|
||||
{children}
|
||||
<CookieBanner />
|
||||
<CookieSettingsModal />
|
||||
</CookieProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { useCookieConsent } from './CookieContext';
|
||||
21
components/grid/index.tsx
Normal file
21
components/grid/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Grid(props: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul {...props} className={clsx('grid grid-flow-row gap-4', props.className)}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItem(props: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li {...props} className={clsx('aspect-square transition-opacity', props.className)}>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Grid.Item = GridItem;
|
||||
|
||||
export default Grid;
|
||||
61
components/grid/three-items.tsx
Normal file
61
components/grid/three-items.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
function ThreeItemGridItem({
|
||||
item,
|
||||
size,
|
||||
priority
|
||||
}: {
|
||||
item: Product;
|
||||
size: 'full' | 'half';
|
||||
priority?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||
>
|
||||
<Link
|
||||
className="relative block aspect-square h-full w-full"
|
||||
href={`/product/${item.handle}`}
|
||||
prefetch={true}
|
||||
>
|
||||
<GridTileImage
|
||||
src={item.featuredImage.url}
|
||||
fill
|
||||
sizes={
|
||||
size === 'full' ? '(min-width: 768px) 66vw, 100vw' : '(min-width: 768px) 33vw, 100vw'
|
||||
}
|
||||
priority={priority}
|
||||
alt={item.title}
|
||||
label={{
|
||||
position: size === 'full' ? 'center' : 'bottom',
|
||||
title: item.title as string,
|
||||
amount: item.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: item.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ThreeItemGrid() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const homepageItems = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-featured-items'
|
||||
});
|
||||
|
||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
components/grid/tile.tsx
Normal file
42
components/grid/tile.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import Label from '../Label';
|
||||
|
||||
export function GridTileImage({
|
||||
isInteractive = true,
|
||||
active,
|
||||
label,
|
||||
...props
|
||||
}: {
|
||||
isInteractive?: boolean;
|
||||
active?: boolean;
|
||||
label?: {
|
||||
title: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
position?: 'bottom' | 'center';
|
||||
};
|
||||
} & React.ComponentProps<typeof Image>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex h-full w-full items-center justify-center overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{props.src ? (
|
||||
<Image
|
||||
className='relative h-full w-full object-contain'
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
<Label
|
||||
title={label.title}
|
||||
amount={label.amount}
|
||||
currencyCode={label.currencyCode}
|
||||
position={label.position}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
components/home/CardSection.tsx
Normal file
56
components/home/CardSection.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { CustomCard } from "@/components/ui/CustomCard";
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
|
||||
export function CardSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Card data from translations
|
||||
const cardsData = [
|
||||
{
|
||||
title: t('cardSection.cards.0.title'),
|
||||
description: t('cardSection.cards.0.description'),
|
||||
imageSrc: "/assets/images/card1.png",
|
||||
imageAlt: t('cardSection.cards.0.imageAlt')
|
||||
},
|
||||
{
|
||||
title: t('cardSection.cards.1.title'),
|
||||
description: t('cardSection.cards.1.description'),
|
||||
imageSrc: "/assets/images/card2.png",
|
||||
imageAlt: t('cardSection.cards.1.imageAlt')
|
||||
},
|
||||
{
|
||||
title: t('cardSection.cards.2.title'),
|
||||
description: t('cardSection.cards.2.description'),
|
||||
imageSrc: "/assets/images/card3.png",
|
||||
imageAlt: t('cardSection.cards.2.imageAlt')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex flex-col items-start">
|
||||
<SectionHeader
|
||||
title={t('cardSection.title')}
|
||||
description={t('cardSection.description')}
|
||||
className="mb-[60px]"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||
{cardsData.map((card, index) => (
|
||||
<CustomCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
imageSrc={card.imageSrc}
|
||||
imageAlt={card.imageAlt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
107
components/home/CustomerReviews.tsx
Normal file
107
components/home/CustomerReviews.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { container } from "@/lib/utils";
|
||||
|
||||
// Define the review data structure
|
||||
interface Review {
|
||||
id: number;
|
||||
author: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export function CustomerReviews() {
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
// Definiraj reviewse po jeziku
|
||||
const reviews: Review[] = locale === 'en' ? [
|
||||
{
|
||||
id: 1,
|
||||
author: "Emily Johnson",
|
||||
comment: "\"The Build a Box feature made gift-giving so easy and special!\""
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: "Michael Smith",
|
||||
comment: "\"Sent's service exceeded my expectations every time!\""
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: "Sarah Lee",
|
||||
comment: "\"The quality of the items was outstanding!\""
|
||||
}
|
||||
] : [
|
||||
{
|
||||
id: 1,
|
||||
author: "Ana Kovačić",
|
||||
comment: "\"Opcija 'Složi kutiju' učinila je darivanje tako jednostavnim i posebnim!\""
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: "Marko Horvat",
|
||||
comment: "\"Usluga Sent-a nadmašila je moja očekivanja svaki put!\""
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: "Ivana Novak",
|
||||
comment: "\"Kvaliteta proizvoda bila je izvanredna!\""
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Section className="bg-white overflow-hidden">
|
||||
{/* Keep the heading inside container */}
|
||||
<div className={container}>
|
||||
<Heading level={2} className="text-center mb-[60px]">
|
||||
{t('customerReviews.title')}
|
||||
</Heading>
|
||||
|
||||
{/* Mobile: Full width scrollable container, Desktop: Grid */}
|
||||
<div className="md:hidden -mx-4">
|
||||
<div className="flex overflow-x-auto pb-6 px-4 snap-x no-scrollbar">
|
||||
{/* Add empty div at start to ensure space */}
|
||||
<div className="shrink-0 w-[5%]"></div>
|
||||
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="bg-secondary shrink-0 w-[85%] sm:w-[70%] h-[235px] p-8 flex flex-col snap-center mx-3"
|
||||
>
|
||||
<Text className="mb-6 flex-grow">
|
||||
{review.comment}
|
||||
</Text>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Text className="text-primary font-bold">{review.author}</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add empty div at end to ensure space */}
|
||||
<div className="shrink-0 w-[5%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original grid layout */}
|
||||
<div className="hidden md:grid md:grid-cols-3 gap-8">
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="bg-secondary h-[235px] p-8 flex flex-col"
|
||||
>
|
||||
<Text className="mb-6 flex-grow">
|
||||
{review.comment}
|
||||
</Text>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Text className="text-primary font-bold">{review.author}</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
73
components/home/GiftBoxBuilder.tsx
Normal file
73
components/home/GiftBoxBuilder.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { container } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
|
||||
export function GiftBoxBuilder() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Top frame image */}
|
||||
<div className="w-full">
|
||||
<Image
|
||||
src="/assets/images/Frame4.png"
|
||||
alt=""
|
||||
width={1440}
|
||||
height={140}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content with pink background */}
|
||||
<div className="bg-[#F58EA7]">
|
||||
<div className={`${container} py-[80px]`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center">
|
||||
{/* Image container - takes up full width on mobile, half on desktop */}
|
||||
<div className="w-full md:w-1/2 h-[240px] md:h-[500px] relative mb-8 md:mb-0">
|
||||
<Image
|
||||
src="/assets/images/build-box.png"
|
||||
alt={t('giftBoxBuilder.altText')}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text container - takes up full width on mobile, half on desktop */}
|
||||
<div className="w-full md:w-1/2 md:pl-20">
|
||||
<Heading level={2} className="mb-4">
|
||||
{t('giftBoxBuilder.title')}
|
||||
</Heading>
|
||||
<Text size="lg" className="mb-8">
|
||||
{t('giftBoxBuilder.description')}
|
||||
</Text>
|
||||
<Button
|
||||
href="/build-box"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
fullWidthMobile
|
||||
>
|
||||
{t('giftBoxBuilder.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom frame image */}
|
||||
<div className="w-full">
|
||||
<Image
|
||||
src="/assets/images/Frame5.png"
|
||||
alt=""
|
||||
width={1440}
|
||||
height={140}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
components/home/HeroCarousel.tsx
Normal file
184
components/home/HeroCarousel.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Carousel } from "@/components/ui/Carousel";
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { CarouselSlide } from "@/lib/types/carousel";
|
||||
import { container } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function HeroCarousel() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Carousel data with translations
|
||||
const carouselData: CarouselSlide[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: t('hero.title'),
|
||||
titleColored: t('hero.title_colored'),
|
||||
titleEnd: t('hero.title_end'),
|
||||
description: t('hero.description'),
|
||||
buttonText: t('hero.build_box_button'),
|
||||
buttonLink: "/build-box",
|
||||
imageSrc: "/assets/images/carousel1.png"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t('hero.title'),
|
||||
titleColored: t('hero.title_colored'),
|
||||
titleEnd: t('hero.title_end'),
|
||||
description: t('hero.description'),
|
||||
buttonText: t('hero.build_box_button'),
|
||||
buttonLink: "/build-box",
|
||||
imageSrc: "/assets/images/carousel2.png"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('hero.title'),
|
||||
titleColored: t('hero.title_colored'),
|
||||
titleEnd: t('hero.title_end'),
|
||||
description: t('hero.description'),
|
||||
buttonText: t('hero.build_box_button'),
|
||||
buttonLink: "/build-box",
|
||||
imageSrc: "/assets/images/carousel1.png"
|
||||
}
|
||||
];
|
||||
|
||||
// Default slide to use as fallback
|
||||
const defaultSlide: CarouselSlide = {
|
||||
id: 0,
|
||||
title: t('hero.title'),
|
||||
titleColored: t('hero.title_colored'),
|
||||
titleEnd: t('hero.title_end'),
|
||||
description: t('hero.description'),
|
||||
buttonText: t('hero.build_box_button'),
|
||||
buttonLink: "/build-box",
|
||||
imageSrc: "/assets/images/image1.png"
|
||||
};
|
||||
|
||||
// Create mobile slide components - each image needs to be in a container div
|
||||
const mobileImageSlides = carouselData.map((slide, index) => (
|
||||
<div key={slide.id} className="relative w-full h-full">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]"></div>
|
||||
|
||||
{/* Image container - right side with padding */}
|
||||
<div className="absolute right-0 top-0 h-full w-1/2">
|
||||
<div className="relative h-full py-8">
|
||||
<Image
|
||||
src={slide.imageSrc}
|
||||
alt={slide.title}
|
||||
fill
|
||||
sizes="50vw"
|
||||
className="object-contain"
|
||||
priority={index === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
// Create desktop slide components
|
||||
const desktopSlides = carouselData.map((slide, index) => (
|
||||
<div key={slide.id} className="w-full h-full">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]"></div>
|
||||
|
||||
{/* Image container - positioned on the right with padding */}
|
||||
<div className="absolute right-0 top-0 h-full w-[46.3%]">
|
||||
<div className="relative h-full py-12">
|
||||
<Image
|
||||
src={slide.imageSrc}
|
||||
alt={slide.title}
|
||||
fill
|
||||
sizes="46.3vw"
|
||||
className="object-contain"
|
||||
priority={index === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container for proper alignment */}
|
||||
<div className={`relative h-full ${container} flex items-end`}>
|
||||
{/* Text overlay */}
|
||||
<div className="pb-32 max-w-2xl">
|
||||
<Heading level={1} className="mb-4 text-black text-[40px] leading-[48px] md:text-[60px] md:leading-[76px] flex flex-col font-bold">
|
||||
<span>{slide.title}</span>
|
||||
<span className="text-primary">{slide.titleColored}</span>
|
||||
<span>{slide.titleEnd}</span>
|
||||
</Heading>
|
||||
<Text size="lg" className="mb-6 text-black text-[18px] leading-[28px]">
|
||||
{slide.description}
|
||||
</Text>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
href={slide.buttonLink}
|
||||
variant="filled"
|
||||
size="lg"
|
||||
>
|
||||
{slide.buttonText}
|
||||
</Button>
|
||||
<Button
|
||||
href="/products"
|
||||
variant="custom"
|
||||
size="lg"
|
||||
>
|
||||
{t('hero.ready_made_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<Section spacing="none" fullWidth={true}>
|
||||
<div className="w-full">
|
||||
{/* Mobile version */}
|
||||
<div className="md:hidden">
|
||||
{/* Image carousel */}
|
||||
<div className="relative w-full h-[350px] bg-gradient-to-r from-[#958C87] via-[#A9A19C] via-[#BDBAB3] via-[#C5C1C0] via-[#C7C1C1] via-[#C3BFBE] to-[#C4C0BF]">
|
||||
<Carousel
|
||||
slides={mobileImageSlides}
|
||||
interval={10000}
|
||||
indicatorsClassName="absolute bottom-4 left-0 right-0"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text content below carousel on mobile */}
|
||||
<div className="p-6 bg-white">
|
||||
<Heading level={1} className="mb-3 text-[40px] leading-[48px] flex flex-col font-bold">
|
||||
<span>{carouselData[0]?.title || defaultSlide.title}</span>
|
||||
<span className="text-primary">{carouselData[0]?.titleColored || defaultSlide.titleColored}</span>
|
||||
<span>{carouselData[0]?.titleEnd || defaultSlide.titleEnd}</span>
|
||||
</Heading>
|
||||
<Text className="mb-6 text-[18px] leading-[28px]">
|
||||
{carouselData[0]?.description || defaultSlide.description}
|
||||
</Text>
|
||||
<Button
|
||||
href={carouselData[0]?.buttonLink || defaultSlide.buttonLink}
|
||||
variant="filled"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
{carouselData[0]?.buttonText || defaultSlide.buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop version */}
|
||||
<div className="hidden md:block relative h-[600px]">
|
||||
<Carousel
|
||||
slides={desktopSlides}
|
||||
interval={10000}
|
||||
indicatorsClassName="absolute bottom-6 left-0 right-0"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
31
components/home/NewHomePage.tsx
Normal file
31
components/home/NewHomePage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CardSection } from "./CardSection";
|
||||
import { CustomerReviews } from "./CustomerReviews";
|
||||
import { GiftBoxBuilder } from "./GiftBoxBuilder";
|
||||
import HeroCarousel from "./HeroCarousel";
|
||||
import { ProductSliderWrapper } from "./ProductSliderWrapper";
|
||||
import { WoltDelivery } from "./WoltDelivery";
|
||||
|
||||
export default function NewHomePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section with Carousel */}
|
||||
<HeroCarousel />
|
||||
|
||||
{/* Card Section */}
|
||||
<CardSection />
|
||||
|
||||
{/* Gift Box Builder Section */}
|
||||
<GiftBoxBuilder />
|
||||
|
||||
{/* Product Slider Section */}
|
||||
<ProductSliderWrapper />
|
||||
|
||||
{/* Customer Reviews Section */}
|
||||
<CustomerReviews />
|
||||
|
||||
{/* Wolt Delivery Section */}
|
||||
<WoltDelivery />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
components/home/ProductSlider.tsx
Normal file
107
components/home/ProductSlider.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { getProductColors } from "@/components/products/utils/colorUtils";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
|
||||
|
||||
interface ProductSliderProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A product slider component that displays products in a horizontal scrollable slider.
|
||||
* Uses the Embla Carousel library for the sliding functionality.
|
||||
*/
|
||||
export function ProductSlider({ products }: ProductSliderProps) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
align: "start",
|
||||
containScroll: false,
|
||||
loop: false,
|
||||
});
|
||||
|
||||
const [prevBtnEnabled, setPrevBtnEnabled] = useState(false);
|
||||
const [nextBtnEnabled, setNextBtnEnabled] = useState(false);
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
setPrevBtnEnabled(emblaApi.canScrollPrev());
|
||||
setNextBtnEnabled(emblaApi.canScrollNext());
|
||||
}, [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
onSelect();
|
||||
emblaApi.on("select", onSelect);
|
||||
emblaApi.on("reInit", onSelect);
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-visible" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={cn(
|
||||
"w-[85%] min-[500px]:w-[90%] min-[700px]:w-[48.5%] min-[1100px]:w-[32%] min-w-0 flex-grow-0 flex-shrink-0 mr-[2%]",
|
||||
)}
|
||||
>
|
||||
<ProductCard
|
||||
title={product.title}
|
||||
variant={product.variants[0]?.title || ""}
|
||||
price={parseFloat(product.priceRange.maxVariantPrice.amount)}
|
||||
imageSrc={product.featuredImage?.url || "/assets/images/placeholder_image.svg"}
|
||||
slug={product.handle}
|
||||
product={product}
|
||||
colors={getProductColors(product)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previous button */}
|
||||
<Button
|
||||
onClick={scrollPrev}
|
||||
disabled={!prevBtnEnabled}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"absolute left-[-18px] top-1/2 transform -translate-y-1/2 z-10",
|
||||
"w-9 h-9 text-sm bg-white shadow-md border border-gray-200 rounded-full",
|
||||
!prevBtnEnabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<FiChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Next button */}
|
||||
<Button
|
||||
onClick={scrollNext}
|
||||
disabled={!nextBtnEnabled}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"absolute right-[-18px] top-1/2 transform -translate-y-1/2 z-10",
|
||||
"w-9 h-9 text-sm bg-white shadow-md border border-gray-200 rounded-full",
|
||||
!nextBtnEnabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<FiChevronRight className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/home/ProductSliderSection.tsx
Normal file
69
components/home/ProductSliderSection.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import type { Product } from "lib/shopify/types";
|
||||
import { ProductSlider } from "./ProductSlider";
|
||||
|
||||
interface ProductSliderSectionProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation component for the product slider section
|
||||
* Handles the layout and presentation aspects, but not data fetching
|
||||
*/
|
||||
export function ProductSliderSection({ products }: ProductSliderSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="overflow-x-hidden w-full">
|
||||
<Section spacing="medium">
|
||||
<div className="mb-8">
|
||||
{/* Container for header section with relative positioning */}
|
||||
<div className="relative">
|
||||
{/* Button positioned absolutely to the right */}
|
||||
<div className="hidden sm:block absolute right-0 bottom-[0px]">
|
||||
<Button
|
||||
href="/search"
|
||||
variant="custom"
|
||||
size="lg"
|
||||
fullWidthMobile={false}
|
||||
>
|
||||
{t('productSlider.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="mb-4">
|
||||
<Heading level={2}>{t('productSlider.title')}</Heading>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="lg" className="mb-4 sm:mb-0">{t('productSlider.description')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile full-width button */}
|
||||
<div className="sm:hidden mt-2">
|
||||
<Button
|
||||
href="/search"
|
||||
variant="custom"
|
||||
size="lg"
|
||||
fullWidthMobile
|
||||
>
|
||||
{t('productSlider.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel container s overflow da se vidi 4. proizvod */}
|
||||
<div className="relative w-full overflow-visible">
|
||||
<ProductSlider products={products} />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/home/ProductSliderWrapper.tsx
Normal file
25
components/home/ProductSliderWrapper.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import { ProductSliderSection } from './ProductSliderSection';
|
||||
|
||||
/**
|
||||
* Server component responsible for fetching product data
|
||||
*/
|
||||
export async function ProductSliderWrapper() {
|
||||
// Fetch products from Shopify
|
||||
// Try to fetch from a dedicated collection first, and if not available, fetch recent products
|
||||
const products = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-carousel',
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
}).catch(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
// If no products found, don't render anything
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render the slider section with fetched products
|
||||
return <ProductSliderSection products={products} />;
|
||||
}
|
||||
90
components/home/WoltDelivery.tsx
Normal file
90
components/home/WoltDelivery.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { container } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
|
||||
export function WoltDelivery() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Top wave */}
|
||||
<div className="w-full">
|
||||
<Image
|
||||
src="/assets/images/Frame3.png"
|
||||
alt=""
|
||||
width={1440}
|
||||
height={140}
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content with background color */}
|
||||
<div className="bg-wolt">
|
||||
<div className={`${container} py-[80px] relative`}>
|
||||
{/* Desktop Wolt logo - only visible on desktop */}
|
||||
<div className="absolute top-0 right-0 z-10 hidden md:block">
|
||||
<div className="w-[160px] h-[160px] bg-wolt-blue rounded-md flex items-center justify-center">
|
||||
<Image
|
||||
src="/assets/images/wolt.png"
|
||||
alt="Wolt"
|
||||
width={120}
|
||||
height={120}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text and image layout */}
|
||||
<div className="flex flex-col md:flex-row md:items-center">
|
||||
{/* Text container - comes first on both mobile and desktop */}
|
||||
<div className="w-full md:w-1/2 md:pr-12 order-1 mb-8 md:mb-0">
|
||||
<Heading level={2} className="mb-4">
|
||||
{t('woltDelivery.title')}
|
||||
</Heading>
|
||||
<Text size="lg" className="mb-8">
|
||||
{t('woltDelivery.description')}
|
||||
</Text>
|
||||
<Button
|
||||
href="https://wolt.com"
|
||||
external
|
||||
variant="filled"
|
||||
size="lg"
|
||||
fullWidthMobile
|
||||
>
|
||||
{t('woltDelivery.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image container with mobile logo */}
|
||||
<div className="w-full md:w-1/2 h-[310px] md:h-[400px] relative order-2 mb-12 md:mb-0">
|
||||
<Image
|
||||
src="/assets/images/wolt-image.png"
|
||||
alt={t('woltDelivery.altText')}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Mobile Wolt logo - positioned at bottom-right of image */}
|
||||
<div className="absolute bottom-[-40px] right-[-10px] z-10 md:hidden">
|
||||
<div className="w-[120px] h-[120px] bg-wolt-blue rounded-md flex items-center justify-center">
|
||||
<Image
|
||||
src="/assets/images/wolt.png"
|
||||
alt="Wolt"
|
||||
width={90}
|
||||
height={90}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/i18n/IntlProvider.tsx
Normal file
18
components/i18n/IntlProvider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type IntlProviderProps = {
|
||||
locale: string;
|
||||
children: ReactNode;
|
||||
messages: any;
|
||||
};
|
||||
|
||||
export function IntlProvider({ locale, messages, children }: IntlProviderProps) {
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
68
components/i18n/LanguageSwitcher.tsx
Normal file
68
components/i18n/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [currentLocale, setCurrentLocale] = useState('hr');
|
||||
|
||||
// Detect current language from URL
|
||||
useEffect(() => {
|
||||
const locale = pathname.startsWith('/en') ? 'en' : 'hr';
|
||||
setCurrentLocale(locale);
|
||||
}, [pathname]);
|
||||
|
||||
const switchLanguage = (locale: string) => {
|
||||
// Set cookie for future visits
|
||||
Cookies.set('NEXT_LOCALE', locale, { expires: 365, path: '/' });
|
||||
|
||||
// Construct proper path based on current path and target locale
|
||||
let newPath;
|
||||
|
||||
if (locale === 'en') {
|
||||
// If switching to English
|
||||
if (pathname.startsWith('/en')) {
|
||||
// Already in English, no need to change path
|
||||
newPath = pathname;
|
||||
} else {
|
||||
// Add /en prefix to current path
|
||||
newPath = `/en${pathname}`;
|
||||
}
|
||||
} else {
|
||||
// If switching to Croatian
|
||||
if (pathname.startsWith('/en')) {
|
||||
// Remove /en prefix
|
||||
newPath = pathname.substring(3) || '/';
|
||||
} else {
|
||||
// Already in Croatian, no need to change path
|
||||
newPath = pathname;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to the new path
|
||||
router.push(newPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => switchLanguage('hr')}
|
||||
className={`px-2 py-1 ${currentLocale === 'hr' ? 'font-bold' : ''}`}
|
||||
aria-label="Prebaci na hrvatski"
|
||||
>
|
||||
HR
|
||||
</button>
|
||||
<span>|</span>
|
||||
<button
|
||||
onClick={() => switchLanguage('en')}
|
||||
className={`px-2 py-1 ${currentLocale === 'en' ? 'font-bold' : ''}`}
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
components/icons/logo.tsx
Normal file
16
components/icons/logo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function LogoIcon(props: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={`${process.env.SITE_NAME} logo`}
|
||||
viewBox="0 0 32 28"
|
||||
{...props}
|
||||
className={clsx('h-4 w-4 fill-black dark:fill-white', props.className)}
|
||||
>
|
||||
<path d="M21.5758 9.75769L16 0L0 28H11.6255L21.5758 9.75769Z" />
|
||||
<path d="M26.2381 17.9167L20.7382 28H32L26.2381 17.9167Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
components/layout/ProductGridItems.tsx
Normal file
32
components/layout/ProductGridItems.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Grid from 'components/grid';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ProductGridItems({ products }: { products: Product[] }) {
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<Grid.Item key={product.handle} className="animate-fadeIn">
|
||||
<Link
|
||||
className="relative inline-block h-full w-full"
|
||||
href={`/product/${product.handle}`}
|
||||
prefetch={true}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
fill
|
||||
sizes="(min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
</Grid.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
components/layout/categories.tsx
Normal file
59
components/layout/categories.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
const categories = {
|
||||
'Men': [
|
||||
{ name: 'T-Shirts', href: '/collections/mens-t-shirts' },
|
||||
{ name: 'Hoodies', href: '/collections/mens-hoodies' },
|
||||
{ name: 'Pants', href: '/collections/mens-pants' },
|
||||
{ name: 'Accessories', href: '/collections/mens-accessories' }
|
||||
],
|
||||
'Women': [
|
||||
{ name: 'Dresses', href: '/collections/womens-dresses' },
|
||||
{ name: 'Tops', href: '/collections/womens-tops' },
|
||||
{ name: 'Bottoms', href: '/collections/womens-bottoms' },
|
||||
{ name: 'Accessories', href: '/collections/womens-accessories' }
|
||||
]
|
||||
};
|
||||
|
||||
export default function Categories() {
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex gap-6">
|
||||
{Object.keys(categories).map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
className="relative py-4 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
onMouseEnter={() => setActiveSection(section)}
|
||||
onMouseLeave={() => setActiveSection(null)}
|
||||
>
|
||||
{section}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{activeSection && (
|
||||
<div
|
||||
className="absolute left-0 top-full z-10 w-48 rounded-md bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
onMouseEnter={() => setActiveSection(activeSection)}
|
||||
onMouseLeave={() => setActiveSection(null)}
|
||||
>
|
||||
{categories[activeSection as keyof typeof categories].map((category) => (
|
||||
<Link
|
||||
key={category.name}
|
||||
href={category.href}
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
components/layout/footer.tsx
Normal file
120
components/layout/footer.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useCookieConsent } from "@/components/cookies";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { container } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation();
|
||||
const { openModal } = useCookieConsent();
|
||||
|
||||
// Definiraj linkove s prijevodima
|
||||
const navLinks = [
|
||||
{ name: t('footer.build_box'), href: "/build-box" },
|
||||
{ name: t('footer.products'), href: "/products" },
|
||||
{ name: t('footer.about'), href: "/about" }
|
||||
];
|
||||
|
||||
const legalLinks = [
|
||||
{ name: t('footer.privacy_policy'), href: "/privacy-policy" },
|
||||
{ name: t('footer.terms_of_service'), href: "/terms-of-service" },
|
||||
{ name: t('footer.cookies_settings'), href: "#", isButton: true }
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ name: t('footer.social.facebook'), href: "https://facebook.com/sentshop", icon: "/assets/images/Facebook.png" },
|
||||
{ name: t('footer.social.instagram'), href: "https://instagram.com/sentshop", icon: "/assets/images/Instagram.png" }
|
||||
];
|
||||
|
||||
const handleCookiesSettings = () => {
|
||||
openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-100">
|
||||
<div className={container}>
|
||||
{/* Main footer content */}
|
||||
<div className="py-12">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center md:justify-start mb-8 md:mb-0">
|
||||
<Link href="/" aria-label="Logo">
|
||||
<Image
|
||||
src="/assets/images/logo.svg"
|
||||
alt="SENT logo"
|
||||
width={125}
|
||||
height={35}
|
||||
className="w-[125px] h-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col items-center md:flex-row md:space-x-8 mb-8 md:mb-0">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="text-[16px] font-bold text-gray-700 hover:text-gray-900 mb-4 md:mb-0"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Social media icons */}
|
||||
<div className="flex items-center justify-center md:justify-end space-x-6 mb-8 md:mb-0">
|
||||
{socialLinks.map((social, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={social.href}
|
||||
aria-label={social.name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<Image
|
||||
src={social.icon}
|
||||
alt={social.name}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal footer */}
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col md:flex-row justify-center items-center text-[14px] leading-[21px] text-gray-500 space-y-4 md:space-y-0 md:space-x-6">
|
||||
<span>© {new Date().getFullYear()} Sent. {t('footer.all_rights_reserved')}</span>
|
||||
{legalLinks.map((link, index) => (
|
||||
link.isButton ? (
|
||||
<button
|
||||
key={index}
|
||||
onClick={handleCookiesSettings}
|
||||
className="hover:text-gray-700 text-[14px] leading-[21px]"
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="hover:text-gray-700 text-[14px] leading-[21px]"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
252
components/layout/navbar/index.tsx
Normal file
252
components/layout/navbar/index.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/i18n/LanguageSwitcher';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { container } from '@/lib/utils';
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Navbar() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { cart } = useCart();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Create links with translations and proper prefix
|
||||
const getNavLinks = () => {
|
||||
const links = [
|
||||
{ name: t('navbar.build_box'), path: '/build-box' },
|
||||
{ name: t('navbar.products'), path: '/products' },
|
||||
{ name: t('navbar.about'), path: '/about' }
|
||||
];
|
||||
|
||||
// Add locale prefix for English paths
|
||||
if (locale === 'en') {
|
||||
return links.map(link => ({
|
||||
...link,
|
||||
href: `/en${link.path}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Return paths as is for Croatian
|
||||
return links.map(link => ({
|
||||
...link,
|
||||
href: link.path
|
||||
}));
|
||||
};
|
||||
|
||||
const navLinks = getNavLinks();
|
||||
|
||||
// Close mobile menu on resize to desktop
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Prevent scrolling when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
// Create home link with proper prefix
|
||||
const homeLink = locale === 'en' ? '/en' : '/';
|
||||
|
||||
// Create cart link with proper prefix
|
||||
const cartLink = locale === 'en' ? '/en/cart' : '/cart';
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-gray-200 sticky top-0 z-40 bg-white">
|
||||
<div className={`${container} flex h-16 items-center justify-between`}>
|
||||
{/* Logo */}
|
||||
<Link href={homeLink} className="flex items-center h-12">
|
||||
<Image
|
||||
src="/assets/images/logo.svg"
|
||||
alt="Logo"
|
||||
width={125}
|
||||
height={35}
|
||||
className="w-[125px] h-auto object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav and Cart */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = pathname === link.href ||
|
||||
(link.href !== homeLink && pathname.startsWith(link.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-primary hover:text-primary-dark'
|
||||
: 'text-black hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
<Link
|
||||
href={cartLink}
|
||||
className="inline-flex h-10 items-center justify-center rounded-button bg-primary px-6 py-2 text-sm font-medium text-white relative hover:bg-primary-dark"
|
||||
>
|
||||
<span>{t('navbar.cart')}</span>
|
||||
<Image
|
||||
src="/assets/images/cart-icon.png"
|
||||
alt="Cart"
|
||||
width={20}
|
||||
height={20}
|
||||
className="ml-2"
|
||||
/>
|
||||
{cart?.totalQuantity ? (
|
||||
<span className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
|
||||
{cart.totalQuantity}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button and cart */}
|
||||
<div className="md:hidden flex items-center space-x-4">
|
||||
<Link
|
||||
href={cartLink}
|
||||
className="relative text-black"
|
||||
aria-label="View cart"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/cart-icon-black.png"
|
||||
alt="Cart"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
{cart?.totalQuantity ? (
|
||||
<span className="absolute -right-2 -top-3 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
|
||||
{cart.totalQuantity}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="text-gray-500"
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-white md:hidden">
|
||||
{/* Mobile menu header - use same container and height as main navbar */}
|
||||
<div className={`${container} flex h-16 items-center justify-between`}>
|
||||
<Link
|
||||
href={homeLink}
|
||||
className="flex items-center h-12"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/logo.svg"
|
||||
alt="Logo"
|
||||
width={125}
|
||||
height={35}
|
||||
className="w-[125px] h-auto object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
className="text-gray-500"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu links */}
|
||||
<div className={`${container} flex-1 flex flex-col py-4`}>
|
||||
<div className="space-y-4">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = pathname === link.href ||
|
||||
(link.href !== homeLink && pathname.startsWith(link.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={`block text-lg font-medium py-2 transition-colors ${
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-black hover:text-primary'
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="py-2">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={cartLink}
|
||||
className="mt-4 flex h-12 w-full items-center justify-center relative"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<span>{t('navbar.cart')}</span>
|
||||
<Image
|
||||
src="/assets/images/cart-icon.png"
|
||||
alt="Cart"
|
||||
width={20}
|
||||
height={20}
|
||||
className="ml-2"
|
||||
/>
|
||||
{cart?.totalQuantity ? (
|
||||
<span className="absolute right-4 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
|
||||
{cart.totalQuantity}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
components/layout/navbar/search.tsx
Normal file
40
components/layout/navbar/search.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import Form from 'next/form';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function Search() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
key={searchParams?.get('q')}
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search for products..."
|
||||
autoComplete="off"
|
||||
defaultValue={searchParams?.get('q') || ''}
|
||||
className="w-full px-4 py-2 border"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
return (
|
||||
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
placeholder="Search for products..."
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
37
components/layout/search/collections.tsx
Normal file
37
components/layout/search/collections.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import FilterList from './filter';
|
||||
|
||||
async function CollectionList() {
|
||||
const collections = await getCollections();
|
||||
return <FilterList list={collections} title="Collections" />;
|
||||
}
|
||||
|
||||
const skeleton = 'mb-3 h-4 w-5/6 animate-pulse rounded';
|
||||
const activeAndTitles = 'bg-neutral-800 dark:bg-neutral-300';
|
||||
const items = 'bg-neutral-400 dark:bg-neutral-700';
|
||||
|
||||
export default function Collections() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="col-span-2 hidden h-[400px] w-full flex-none py-4 lg:block">
|
||||
<div className={clsx(skeleton, activeAndTitles)} />
|
||||
<div className={clsx(skeleton, activeAndTitles)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
<div className={clsx(skeleton, items)} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CollectionList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
64
components/layout/search/filter/dropdown.tsx
Normal file
64
components/layout/search/filter/dropdown.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import type { ListItem } from '.';
|
||||
import { FilterItem } from './item';
|
||||
|
||||
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [active, setActive] = useState('');
|
||||
const [openSelect, setOpenSelect] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpenSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
return () => window.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
list.forEach((listItem: ListItem) => {
|
||||
if (
|
||||
('path' in listItem && pathname === listItem.path) ||
|
||||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
|
||||
) {
|
||||
setActive(listItem.title);
|
||||
}
|
||||
});
|
||||
}, [pathname, list, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpenSelect(!openSelect);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
|
||||
>
|
||||
<div>{active}</div>
|
||||
<ChevronDownIcon className="h-4" />
|
||||
</div>
|
||||
{openSelect && (
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpenSelect(false);
|
||||
}}
|
||||
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
|
||||
>
|
||||
{list.map((item: ListItem, i) => (
|
||||
<FilterItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
components/layout/search/filter/index.tsx
Normal file
41
components/layout/search/filter/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SortFilterItem } from 'lib/constants';
|
||||
import { Suspense } from 'react';
|
||||
import FilterItemDropdown from './dropdown';
|
||||
import { FilterItem } from './item';
|
||||
|
||||
export type ListItem = SortFilterItem | PathFilterItem;
|
||||
export type PathFilterItem = { title: string; path: string };
|
||||
|
||||
function FilterItemList({ list }: { list: ListItem[] }) {
|
||||
return (
|
||||
<>
|
||||
{list.map((item: ListItem, i) => (
|
||||
<FilterItem key={i} item={item} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
|
||||
return (
|
||||
<>
|
||||
<nav>
|
||||
{title ? (
|
||||
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
|
||||
{title}
|
||||
</h3>
|
||||
) : null}
|
||||
<ul className="hidden md:block">
|
||||
<Suspense fallback={null}>
|
||||
<FilterItemList list={list} />
|
||||
</Suspense>
|
||||
</ul>
|
||||
<ul className="md:hidden">
|
||||
<Suspense fallback={null}>
|
||||
<FilterItemDropdown list={list} />
|
||||
</Suspense>
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
components/layout/search/filter/item.tsx
Normal file
67
components/layout/search/filter/item.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import type { SortFilterItem } from 'lib/constants';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import type { ListItem, PathFilterItem } from '.';
|
||||
|
||||
function PathFilterItem({ item }: { item: PathFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const active = pathname === item.path;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
|
||||
newParams.delete('q');
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
|
||||
<DynamicTag
|
||||
href={createUrl(item.path, newParams)}
|
||||
className={clsx(
|
||||
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100',
|
||||
{
|
||||
'underline underline-offset-4': active
|
||||
}
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SortFilterItem({ item }: { item: SortFilterItem }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const active = searchParams.get('sort') === item.slug;
|
||||
const q = searchParams.get('q');
|
||||
const href = createUrl(
|
||||
pathname,
|
||||
new URLSearchParams({
|
||||
...(q && { q }),
|
||||
...(item.slug && item.slug.length && { sort: item.slug })
|
||||
})
|
||||
);
|
||||
const DynamicTag = active ? 'p' : Link;
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
|
||||
<DynamicTag
|
||||
prefetch={!active ? false : undefined}
|
||||
href={href}
|
||||
className={clsx('w-full hover:underline hover:underline-offset-4', {
|
||||
'underline underline-offset-4': active
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterItem({ item }: { item: ListItem }) {
|
||||
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
|
||||
}
|
||||
23
components/logo-square.tsx
Normal file
23
components/logo-square.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import LogoIcon from './icons/logo';
|
||||
|
||||
export default function LogoSquare({ size }: { size?: 'sm' | undefined }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-none items-center justify-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-black',
|
||||
{
|
||||
'h-[40px] w-[40px] rounded-xl': !size,
|
||||
'h-[30px] w-[30px] rounded-lg': size === 'sm'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<LogoIcon
|
||||
className={clsx({
|
||||
'h-[16px] w-[16px]': !size,
|
||||
'h-[10px] w-[10px]': size === 'sm'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
components/opengraph-image.tsx
Normal file
94
components/opengraph-image.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// ?title=<title>
|
||||
const hasTitle = searchParams.has('title');
|
||||
const title = hasTitle
|
||||
? searchParams.get('title')?.slice(0, 100)
|
||||
: 'My default title';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 60,
|
||||
fontStyle: 'normal',
|
||||
letterSpacing: '-0.025em',
|
||||
color: 'black',
|
||||
marginTop: 30,
|
||||
padding: '0 120px',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.log(`${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return new Response(`Failed to generate the image`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default export za korištenje u app/search/[collection]/opengraph-image.tsx
|
||||
export default async function OpengraphImage({ title = 'My default title' }: { title?: string }) {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 60,
|
||||
fontStyle: 'normal',
|
||||
letterSpacing: '-0.025em',
|
||||
color: 'black',
|
||||
marginTop: 30,
|
||||
padding: '0 120px',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
24
components/price.tsx
Normal file
24
components/price.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
const Price = ({
|
||||
amount,
|
||||
className,
|
||||
currencyCode = 'USD',
|
||||
currencyCodeClassName
|
||||
}: {
|
||||
amount: string;
|
||||
className?: string;
|
||||
currencyCode: string;
|
||||
currencyCodeClassName?: string;
|
||||
} & React.ComponentProps<'p'>) => (
|
||||
<p suppressHydrationWarning={true} className={className}>
|
||||
{`${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
currencyDisplay: 'narrowSymbol'
|
||||
}).format(parseFloat(amount))}`}
|
||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
export default Price;
|
||||
33
components/product/BackButton.tsx
Normal file
33
components/product/BackButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function BackButton() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoBack = () => {
|
||||
// Check if we can go back in history
|
||||
if (window.history.length > 1) {
|
||||
// Go back to previous page
|
||||
router.back();
|
||||
} else {
|
||||
// Fallback to home page if there's no history
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="flex items-center text-sm text-gray-600 hover:text-black"
|
||||
aria-label={t('product.back')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('product.back')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
38
components/product/CollapsibleSection.tsx
Normal file
38
components/product/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from '@/components/ui/Typography';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
children,
|
||||
isOpen = false,
|
||||
className = ''
|
||||
}: CollapsibleSectionProps) {
|
||||
return (
|
||||
<details className={`group mb-4 ${className}`} open={isOpen}>
|
||||
<summary className="flex cursor-pointer items-center justify-between py-2">
|
||||
<Heading level={3} className="font-medium">{title}</Heading>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="pt-2 pb-6">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
47
components/product/ProductDescription.tsx
Normal file
47
components/product/ProductDescription.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
import { Heading, Label } from '@/components/ui/Typography';
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { useState } from 'react';
|
||||
import { ProductQuantity } from './ProductQuantity';
|
||||
import { VariantSelector } from './VariantSelector';
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="pt-5 overflow-hidden">
|
||||
<div className="mb-6 flex flex-col pb-4 border-b">
|
||||
<Heading level={1} className="mb-3">{product.title}</Heading>
|
||||
<div className="mr-auto w-auto">
|
||||
<Price
|
||||
amount={product.priceRange.maxVariantPrice.amount}
|
||||
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||
className="text-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.descriptionHtml ? (
|
||||
<Prose
|
||||
className="mb-6 text-gray-600"
|
||||
html={product.descriptionHtml}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<VariantSelector options={product.options} variants={product.variants} />
|
||||
|
||||
<div className="mt-8 mb-6">
|
||||
<Label className="mb-2">Quantity</Label>
|
||||
<ProductQuantity
|
||||
onChange={setQuantity}
|
||||
initialQuantity={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddToCart product={product} quantity={quantity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/product/ProductDetailsSection.tsx
Normal file
36
components/product/ProductDetailsSection.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
|
||||
export function ProductDetailsSection() {
|
||||
return (
|
||||
<div className="mt-8 border-t pt-8">
|
||||
<CollapsibleSection title="Details" isOpen={true}>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
|
||||
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
|
||||
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
|
||||
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Shipping" className="border-t">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
|
||||
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
|
||||
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
|
||||
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Returns" className="border-t">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in
|
||||
eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum
|
||||
nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id
|
||||
rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/product/ProductGallery.tsx
Normal file
60
components/product/ProductGallery.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Image as ImageType } from 'lib/shopify/types';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
images: ImageType[];
|
||||
}
|
||||
|
||||
export function ProductGallery({ images = [] }: ProductGalleryProps) {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className="aspect-square w-full bg-gray-100 flex items-center justify-center">
|
||||
<p className="text-gray-500">No image available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-3 md:items-start pt-5 w-full overflow-hidden">
|
||||
{/* Thumbnails */}
|
||||
<div className="order-2 md:order-1 flex md:flex-col gap-3 overflow-x-auto md:overflow-y-auto max-h-[600px] md:w-16 flex-shrink-0">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={`${image.url}-${index}`}
|
||||
className={`relative h-14 w-14 flex-shrink-0 overflow-hidden border ${
|
||||
selectedImage === index ? 'border-black' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
aria-label={`View image ${index + 1} of ${images.length}`}
|
||||
>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.altText || ''}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main image */}
|
||||
<div className="order-1 md:order-2 relative w-full h-[500px] md:h-[550px] flex-grow overflow-hidden">
|
||||
<div className="w-full h-full relative">
|
||||
{images[selectedImage] && (
|
||||
<img
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].altText || ''}
|
||||
className="w-full h-full object-cover object-top"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/product/ProductQuantity.tsx
Normal file
71
components/product/ProductQuantity.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ProductQuantityProps {
|
||||
onChange: (quantity: number) => void;
|
||||
initialQuantity?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function ProductQuantity({
|
||||
onChange,
|
||||
initialQuantity = 1,
|
||||
min = 1,
|
||||
max = 100
|
||||
}: ProductQuantityProps) {
|
||||
const [quantity, setQuantity] = useState(initialQuantity);
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (quantity < max) {
|
||||
const newQuantity = quantity + 1;
|
||||
setQuantity(newQuantity);
|
||||
onChange(newQuantity);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (quantity > min) {
|
||||
const newQuantity = quantity - 1;
|
||||
setQuantity(newQuantity);
|
||||
onChange(newQuantity);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value >= min && value <= max) {
|
||||
setQuantity(value);
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleDecrement}
|
||||
type="button"
|
||||
className="w-10 h-10 border border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:bg-gray-50"
|
||||
disabled={quantity <= min}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={quantity}
|
||||
onChange={handleInputChange}
|
||||
className="w-16 h-10 border-t border-b border-gray-300 text-center focus:outline-none"
|
||||
aria-label="Količina"
|
||||
/>
|
||||
<button
|
||||
onClick={handleIncrement}
|
||||
type="button"
|
||||
className="w-10 h-10 border border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:bg-gray-50"
|
||||
disabled={quantity >= max}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
components/product/VariantSelector.tsx
Normal file
93
components/product/VariantSelector.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
|
||||
type Combination = {
|
||||
id: string;
|
||||
availableForSale: boolean;
|
||||
[key: string]: string | boolean;
|
||||
};
|
||||
|
||||
export function VariantSelector({
|
||||
options,
|
||||
variants
|
||||
}: {
|
||||
options: ProductOption[];
|
||||
variants: ProductVariant[];
|
||||
}) {
|
||||
const { state, updateOption } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const hasNoOptionsOrJustOneOption =
|
||||
!options.length || (options.length === 1 && options[0]?.values.length === 1);
|
||||
|
||||
if (hasNoOptionsOrJustOneOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinations: Combination[] = variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
availableForSale: variant.availableForSale,
|
||||
...variant.selectedOptions.reduce(
|
||||
(accumulator, option) => ({ ...accumulator, [option.name.toLowerCase()]: option.value }),
|
||||
{}
|
||||
)
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
<form key={option.id}>
|
||||
<dl className="mb-8">
|
||||
<dt className="mb-4">{option.name}</dt>
|
||||
<dd className="flex flex-wrap gap-3">
|
||||
{option.values.map((value) => {
|
||||
const optionNameLowerCase = option.name.toLowerCase();
|
||||
|
||||
// Base option params on current selectedOptions so we can preserve any other param state.
|
||||
const optionParams = { ...state, [optionNameLowerCase]: value };
|
||||
|
||||
// Filter out invalid options and check if the option combination is available for sale.
|
||||
const filtered = Object.entries(optionParams).filter(([key, value]) =>
|
||||
options.find(
|
||||
(option) => option.name.toLowerCase() === key && option.values.includes(value)
|
||||
)
|
||||
);
|
||||
const isAvailableForSale = combinations.find((combination) =>
|
||||
filtered.every(
|
||||
([key, value]) => combination[key] === value && combination.availableForSale
|
||||
)
|
||||
);
|
||||
|
||||
// The option is active if it's in the selected options.
|
||||
const isActive = state[optionNameLowerCase] === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateOption(optionNameLowerCase, value);
|
||||
updateURL(newState);
|
||||
}}
|
||||
key={value}
|
||||
aria-disabled={!isAvailableForSale}
|
||||
disabled={!isAvailableForSale}
|
||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||
className={clsx(
|
||||
'flex min-w-[48px] items-center justify-center px-2 py-1',
|
||||
{
|
||||
'cursor-default ring-2': isActive,
|
||||
'ring-1 ring-transparent':
|
||||
!isActive && isAvailableForSale,
|
||||
'relative z-10 cursor-not-allowed overflow-hidden':
|
||||
!isAvailableForSale
|
||||
}
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</dd>
|
||||
</dl>
|
||||
</form>
|
||||
));
|
||||
}
|
||||
92
components/product/gallery.tsx
Normal file
92
components/product/gallery.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||
const { state, updateImage } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const imageIndex = state.image ? parseInt(state.image) : 0;
|
||||
|
||||
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||
|
||||
const buttonClassName =
|
||||
'h-full px-6 flex items-center justify-center';
|
||||
|
||||
return (
|
||||
<form>
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden">
|
||||
{images[imageIndex] && (
|
||||
<Image
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||
alt={images[imageIndex]?.altText as string}
|
||||
src={images[imageIndex]?.src as string}
|
||||
priority={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||
<div className="mx-auto flex h-11 items-center">
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateImage(previousImageIndex.toString());
|
||||
updateURL(newState);
|
||||
}}
|
||||
aria-label="Previous product image"
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ArrowLeftIcon className="h-5" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px "></div>
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateImage(nextImageIndex.toString());
|
||||
updateURL(newState);
|
||||
}}
|
||||
aria-label="Next product image"
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ArrowRightIcon className="h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<ul className="my-12 flex items-center flex-wrap justify-center gap-2 overflow-auto py-1 lg:mb-0">
|
||||
{images.map((image, index) => {
|
||||
const isActive = index === imageIndex;
|
||||
|
||||
return (
|
||||
<li key={image.src} className="h-20 w-20">
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateImage(index.toString());
|
||||
updateURL(newState);
|
||||
}}
|
||||
aria-label="Select product image"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
width={80}
|
||||
height={80}
|
||||
active={isActive}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
81
components/product/product-context.tsx
Normal file
81
components/product/product-context.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { createContext, useContext, useMemo, useOptimistic } from 'react';
|
||||
|
||||
type ProductState = {
|
||||
[key: string]: string;
|
||||
} & {
|
||||
image?: string;
|
||||
};
|
||||
|
||||
type ProductContextType = {
|
||||
state: ProductState;
|
||||
updateOption: (name: string, value: string) => ProductState;
|
||||
updateImage: (index: string) => ProductState;
|
||||
};
|
||||
|
||||
const ProductContext = createContext<ProductContextType | undefined>(undefined);
|
||||
|
||||
export function ProductProvider({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitialState = () => {
|
||||
const params: ProductState = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const [state, setOptimisticState] = useOptimistic(
|
||||
getInitialState(),
|
||||
(prevState: ProductState, update: ProductState) => ({
|
||||
...prevState,
|
||||
...update
|
||||
})
|
||||
);
|
||||
|
||||
const updateOption = (name: string, value: string) => {
|
||||
const newState = { [name]: value };
|
||||
setOptimisticState(newState);
|
||||
return { ...state, ...newState };
|
||||
};
|
||||
|
||||
const updateImage = (index: string) => {
|
||||
const newState = { image: index };
|
||||
setOptimisticState(newState);
|
||||
return { ...state, ...newState };
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
updateOption,
|
||||
updateImage
|
||||
}),
|
||||
[state]
|
||||
);
|
||||
|
||||
return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>;
|
||||
}
|
||||
|
||||
export function useProduct() {
|
||||
const context = useContext(ProductContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useProduct must be used within a ProductProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useUpdateURL() {
|
||||
const router = useRouter();
|
||||
|
||||
return (state: ProductState) => {
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
Object.entries(state).forEach(([key, value]) => {
|
||||
newParams.set(key, value);
|
||||
});
|
||||
router.push(`?${newParams.toString()}`, { scroll: false });
|
||||
};
|
||||
}
|
||||
62
components/products/AddToBoxButton.tsx
Normal file
62
components/products/AddToBoxButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch } from "@/lib/redux/hooks";
|
||||
import { addToBox } from "@/lib/redux/slices/boxSlice";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AddToBoxButtonProps {
|
||||
productId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
image: string;
|
||||
variantId?: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AddToBoxButton({
|
||||
productId,
|
||||
name,
|
||||
price,
|
||||
image,
|
||||
variantId,
|
||||
color,
|
||||
className = ""
|
||||
}: AddToBoxButtonProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAddToBox = () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
dispatch(addToBox({
|
||||
id: productId,
|
||||
name,
|
||||
price,
|
||||
image,
|
||||
quantity: 1,
|
||||
variantId,
|
||||
color
|
||||
}));
|
||||
|
||||
// Add a small delay to simulate adding to cart for better UX
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToBox}
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className={`w-full ${className}`}
|
||||
>
|
||||
{isLoading ? "Dodaje se..." : "Dodaj u box"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
477
components/products/FilterSidebar.tsx
Normal file
477
components/products/FilterSidebar.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { RadioButton } from '@/components/ui/RadioButton';
|
||||
import { Heading } from '@/components/ui/Typography';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SortDropdown } from './client/SortDropdown';
|
||||
|
||||
interface FilterSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApplyFilters: (filters: string[]) => void;
|
||||
activeFilters: string[];
|
||||
}
|
||||
|
||||
interface FilterCategory {
|
||||
name: string;
|
||||
type?: 'checkbox' | 'radio' | 'dropdown';
|
||||
options: string[];
|
||||
}
|
||||
|
||||
const FILTER_CATEGORIES: FilterCategory[] = [
|
||||
{
|
||||
name: 'Filter One',
|
||||
type: 'checkbox',
|
||||
options: ['Test One', 'Test Two', 'Test Three', 'Test Four', 'Test Five']
|
||||
},
|
||||
{
|
||||
name: 'Filter Two',
|
||||
type: 'radio',
|
||||
options: ['All', 'Option One', 'Option Two', 'Option Three', 'Option Four', 'Option Five']
|
||||
},
|
||||
{
|
||||
name: 'Filter Six',
|
||||
type: 'dropdown',
|
||||
options: ['Select 1', 'Select 2', 'Select 3']
|
||||
}
|
||||
];
|
||||
|
||||
// Helper function to create a unique filter key
|
||||
const getFilterKey = (category: string, option: string) => {
|
||||
return `${category}:${option}`;
|
||||
};
|
||||
|
||||
// Helper function to extract the option from a filter key
|
||||
const getOptionFromKey = (filterKey: string) => {
|
||||
const parts = filterKey.split(':');
|
||||
return parts.length > 1 ? parts[1] : filterKey;
|
||||
};
|
||||
|
||||
// Helper function to extract the category from a filter key
|
||||
const getCategoryFromKey = (filterKey: string) => {
|
||||
const parts = filterKey.split(':');
|
||||
return parts.length > 1 ? parts[0] : '';
|
||||
};
|
||||
|
||||
export function FilterSidebar({ isOpen, onClose, onApplyFilters, activeFilters }: FilterSidebarProps) {
|
||||
const [minPrice, setMinPrice] = useState(5);
|
||||
const [maxPrice, setMaxPrice] = useState(100);
|
||||
const [localFilters, setLocalFilters] = useState<string[]>([]);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const rangeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update the range slider fill with primary color
|
||||
const updateRangeStyle = () => {
|
||||
if (rangeRef.current) {
|
||||
const percentage1 = Math.min((minPrice / 100) * 100, 100);
|
||||
const percentage2 = Math.min((maxPrice / 100) * 100, 100);
|
||||
rangeRef.current.style.background = `linear-gradient(to right, #e5e7eb ${percentage1}%, var(--primary-color, #D94D72) ${percentage1}%, var(--primary-color, #D94D72) ${percentage2}%, #e5e7eb ${percentage2}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// Update range fill on mount and when prices change
|
||||
useEffect(() => {
|
||||
updateRangeStyle();
|
||||
}, [minPrice, maxPrice]);
|
||||
|
||||
// Sync activeFilters with localFilters when sidebar opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalFilters([...activeFilters]);
|
||||
|
||||
// Check for price filter in active filters
|
||||
const priceFilter = activeFilters.find(filter => filter.startsWith('Cijena:'));
|
||||
if (priceFilter) {
|
||||
const priceMatch = priceFilter.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
setMinPrice(parseInt(priceMatch[1]));
|
||||
setMaxPrice(parseInt(priceMatch[2]));
|
||||
}
|
||||
} else {
|
||||
// Reset to default values if no price filter
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
}
|
||||
|
||||
// Reset scroll position when sidebar opens
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeFilters]);
|
||||
|
||||
// Lock body scroll when sidebar is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleFilter = (category: string, option: string) => {
|
||||
const filterKey = getFilterKey(category, option);
|
||||
if (localFilters.includes(filterKey)) {
|
||||
setLocalFilters(localFilters.filter(f => f !== filterKey));
|
||||
} else {
|
||||
setLocalFilters([...localFilters, filterKey]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setLocalFilters([]);
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
};
|
||||
|
||||
const clearFilterCategory = (category: string) => {
|
||||
setLocalFilters(localFilters.filter(f => !f.startsWith(`${category}:`)));
|
||||
};
|
||||
|
||||
const clearPriceRange = () => {
|
||||
// Reset price values
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
|
||||
// Remove any price filter tags
|
||||
setLocalFilters(localFilters.filter(f => !f.startsWith('Cijena:')));
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
// Create a copy of localFilters, removing any existing price filters
|
||||
let filtersToApply = localFilters.filter(f => !f.startsWith('Cijena:'));
|
||||
|
||||
// Ensure min price doesn't exceed max price
|
||||
let finalMinPrice = minPrice;
|
||||
let finalMaxPrice = maxPrice;
|
||||
|
||||
if (finalMinPrice > finalMaxPrice) {
|
||||
finalMinPrice = finalMaxPrice;
|
||||
}
|
||||
|
||||
// Add price range as a filter tag
|
||||
if (finalMinPrice > 5 || finalMaxPrice !== 100) {
|
||||
filtersToApply.push(`Cijena: ${finalMinPrice}€ - ${finalMaxPrice}€`);
|
||||
}
|
||||
|
||||
onApplyFilters(filtersToApply);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle price range min input change
|
||||
const handleMinPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
setMinPrice(value);
|
||||
// If min exceeds max, adjust max as well
|
||||
if (value > maxPrice) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle price range max input change
|
||||
const handleMaxPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract filter option handlers for cleaner code
|
||||
const handleRadioChange = (category: string, option: string, categoryOptions: string[]) => {
|
||||
// Get all filter keys for this category
|
||||
const categoryFilterPrefix = `${category}:`;
|
||||
|
||||
// Remove previous selections from this category
|
||||
const filtersWithoutCategory = localFilters.filter(f => !f.startsWith(categoryFilterPrefix));
|
||||
|
||||
if (option === 'All') {
|
||||
// If 'All' is selected, don't add any new filter for this category
|
||||
setLocalFilters(filtersWithoutCategory);
|
||||
} else {
|
||||
// Add the new selected option with category prefix
|
||||
setLocalFilters([...filtersWithoutCategory, getFilterKey(category, option)]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (category: string, value: string) => {
|
||||
if (value) {
|
||||
// Get all filter keys for this category
|
||||
const categoryFilterPrefix = `${category}:`;
|
||||
|
||||
// Remove previous selections from this category
|
||||
const filtersWithoutCategory = localFilters.filter(f => !f.startsWith(categoryFilterPrefix));
|
||||
|
||||
// Add the new selected option with category prefix
|
||||
setLocalFilters([...filtersWithoutCategory, getFilterKey(category, value)]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay - adjusted to only cover the product grid area */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 bg-black bg-opacity-50 z-30"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
top: '0', // Same as sidebar top position
|
||||
bottom: '0', // Allow footer to be visible
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - positioned exactly below navbar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`fixed left-0 h-[calc(100vh-64px)] w-[350px] bg-white z-40 transform transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} overflow-y-auto shadow-lg`}
|
||||
style={{ top: '64px', margin: 0, padding: 0 }} // Exactly at navbar bottom with no space
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Heading level={4} className="font-semibold">Filteri</Heading>
|
||||
<button onClick={onClose} className="text-black">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<hr className="border-gray-200 mb-4" />
|
||||
|
||||
{/* Filter sections */}
|
||||
<div className="space-y-4">
|
||||
{/* Checkbox Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[0]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[0]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{FILTER_CATEGORIES[0]?.options.map((option) => (
|
||||
<div key={option}>
|
||||
<Checkbox
|
||||
id={option}
|
||||
checked={localFilters.includes(getFilterKey(FILTER_CATEGORIES[0]?.name || '', option))}
|
||||
onChange={() => toggleFilter(FILTER_CATEGORIES[0]?.name || '', option)}
|
||||
label={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Radio Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[1]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[1]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{FILTER_CATEGORIES[1]?.options.map((option) => (
|
||||
<div key={option}>
|
||||
<RadioButton
|
||||
id={`radio-${option}`}
|
||||
name="filter-two"
|
||||
value={option}
|
||||
checked={
|
||||
option === 'All'
|
||||
? !localFilters.some(f => f.startsWith(`${FILTER_CATEGORIES[1]?.name}:`) && !f.endsWith(':All'))
|
||||
: localFilters.includes(getFilterKey(FILTER_CATEGORIES[1]?.name || '', option))
|
||||
}
|
||||
onChange={() => handleRadioChange(FILTER_CATEGORIES[1]?.name || '', option, FILTER_CATEGORIES[1]?.options || [])}
|
||||
label={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Dropdown Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[2]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[2]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SortDropdown
|
||||
value={localFilters.find(f => f.startsWith(`${FILTER_CATEGORIES[2]?.name}:`))?.split(':')[1] || ''}
|
||||
onChange={(value) => handleDropdownChange(FILTER_CATEGORIES[2]?.name || '', value)}
|
||||
label=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Price Range */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">Cijena</Heading>
|
||||
<button
|
||||
onClick={clearPriceRange}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Min-Max Range Inputs */}
|
||||
<div className="flex space-x-4 mb-4">
|
||||
<div className="w-1/2">
|
||||
<label htmlFor="min-price" className="text-sm block mb-1">Od</label>
|
||||
<input
|
||||
type="number"
|
||||
id="min-price"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={handleMinPriceChange}
|
||||
className="w-full border border-gray-300 rounded-md p-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<label htmlFor="max-price" className="text-sm block mb-1">Do</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max-price"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={handleMaxPriceChange}
|
||||
className="w-full border border-gray-300 rounded-md p-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dual Range Slider */}
|
||||
<div className="relative pt-5 pb-8">
|
||||
<div
|
||||
ref={rangeRef}
|
||||
className="w-full h-1 bg-gray-200 rounded absolute top-6"
|
||||
></div>
|
||||
|
||||
{/* Min range slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.min(minPrice, 100)}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value < maxPrice) {
|
||||
setMinPrice(value);
|
||||
}
|
||||
}}
|
||||
className="absolute top-4 w-full pointer-events-none appearance-none bg-transparent z-30"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Max range slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.min(maxPrice, 100)}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value > minPrice) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
}}
|
||||
className="absolute top-4 w-full pointer-events-none appearance-none bg-transparent z-30"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
<style jsx>{`
|
||||
:root {
|
||||
--primary-color: #D94D72;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
pointer-events: all;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--primary-color, #D94D72);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
pointer-events: all;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--primary-color, #D94D72);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-sm">{minPrice}€</span>
|
||||
<span className="text-sm">{maxPrice}€</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="mt-8">
|
||||
<hr className="border-gray-200 mb-4" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Obriši sve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Filtriraj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
components/products/ProductComponents/FilterButton.tsx
Normal file
25
components/products/ProductComponents/FilterButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
interface FilterButtonProps {
|
||||
onClick: () => void;
|
||||
filterText?: string;
|
||||
}
|
||||
|
||||
export function FilterButton({ onClick, filterText = "Filteri" }: FilterButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="border border-gray-300 px-4 py-2 rounded-md flex items-center"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h18M3 12h18M3 20h18" />
|
||||
</svg>
|
||||
<span>{filterText}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
52
components/products/ProductComponents/FilterTagList.tsx
Normal file
52
components/products/ProductComponents/FilterTagList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
interface FilterTagListProps {
|
||||
filters: string[];
|
||||
formatTag: (filter: string) => string;
|
||||
onRemove: (filter: string) => void;
|
||||
onClearAll: () => void;
|
||||
clearAllText?: string;
|
||||
}
|
||||
|
||||
export function FilterTagList({
|
||||
filters,
|
||||
formatTag,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
clearAllText = "Očisti sve"
|
||||
}: FilterTagListProps) {
|
||||
if (!filters || filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((filter) => (
|
||||
<div
|
||||
key={filter}
|
||||
className="bg-gray-100 text-sm py-1 px-3 rounded-full flex items-center"
|
||||
>
|
||||
<span>{formatTag(filter)}</span>
|
||||
<button
|
||||
onClick={() => onRemove(filter)}
|
||||
className="ml-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 8.586l3.293-3.293a1 1 0 111.414 1.414L11.414 10l3.293 3.293a1 1 0 01-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 01-1.414-1.414L8.586 10 5.293 6.707a1 1 0 011.414-1.414L10 8.586z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-primary text-sm font-medium hover:underline"
|
||||
>
|
||||
{clearAllText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/products/ProductComponents/ProductCounter.tsx
Normal file
13
components/products/ProductComponents/ProductCounter.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
interface ProductCounterProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ProductCounter({ count }: ProductCounterProps) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
Prikazuje se {count} proizvoda
|
||||
</p>
|
||||
);
|
||||
}
|
||||
29
components/products/ProductComponents/ProductsList.tsx
Normal file
29
components/products/ProductComponents/ProductsList.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { getProductColors } from "@/components/products/utils/colorUtils";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
interface ProductsListProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductsList({ products }: ProductsListProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-12">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="h-full">
|
||||
<ProductCard
|
||||
title={product.title}
|
||||
variant={product.variants[0]?.title || ""}
|
||||
price={parseFloat(product.priceRange.maxVariantPrice.amount)}
|
||||
imageSrc={product.featuredImage?.url || "/assets/images/placeholder_image.svg"}
|
||||
slug={product.handle}
|
||||
product={product}
|
||||
colors={getProductColors(product)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/products/ProductComponents/SortDropdown.tsx
Normal file
25
components/products/ProductComponents/SortDropdown.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SortDropdown({ value, onChange }: SortDropdownProps) {
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
className="border border-gray-300 px-4 py-2 rounded-md"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="default">Poredaj po</option>
|
||||
<option value="newest">Najnovije</option>
|
||||
<option value="price-asc">Cijena: Od niže prema višoj</option>
|
||||
<option value="price-desc">Cijena: Od više prema nižoj</option>
|
||||
<option value="name-asc">Naziv: A-Z</option>
|
||||
<option value="name-desc">Naziv: Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/products/ProductGrid.tsx
Normal file
77
components/products/ProductGrid.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { Heading } from "../ui/Typography";
|
||||
import { FilterSidebar } from "./FilterSidebar";
|
||||
import { FilterButton } from "./ProductComponents/FilterButton";
|
||||
import { FilterTagList } from "./ProductComponents/FilterTagList";
|
||||
import { ProductCounter } from "./ProductComponents/ProductCounter";
|
||||
import { ProductsList } from "./ProductComponents/ProductsList";
|
||||
import { SortDropdown } from "./client/SortDropdown";
|
||||
import { useProductFilters } from "./hooks/useProductFilters";
|
||||
|
||||
interface ProductGridProps {
|
||||
products: Product[];
|
||||
title?: string;
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
export function ProductGrid({ products, title = "Gotovi proizvodi", showControls = true }: ProductGridProps) {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
{title && <Heading level={2} className="mb-8">{title}</Heading>}
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
{showControls && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton onClick={() => setSidebarOpen(true)} />
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter tags and product count */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<FilterTagList
|
||||
filters={activeFilters}
|
||||
formatTag={formatFilterTag}
|
||||
onRemove={removeFilter}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<ProductCounter count={sortedProducts.length} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Products grid */}
|
||||
<ProductsList products={showControls ? sortedProducts : products} />
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
{showControls && (
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
components/products/ProductsPage.tsx
Normal file
11
components/products/ProductsPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getRegularProducts } from "lib/shopify";
|
||||
import { ProductsPageContent } from "./client/ProductsPageContent";
|
||||
|
||||
export async function ProductsPage() {
|
||||
const products = await getRegularProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
return <ProductsPageContent products={products} />;
|
||||
}
|
||||
17
components/products/client/ProductCounter.tsx
Normal file
17
components/products/client/ProductCounter.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
|
||||
interface ProductCounterProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ProductCounter({ count }: ProductCounterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('products.counter.showing').replace('{count}', count.toString())}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
75
components/products/client/ProductGridWithTranslation.tsx
Normal file
75
components/products/client/ProductGridWithTranslation.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { Heading } from "../../ui/Typography";
|
||||
import { FilterSidebar } from "../FilterSidebar";
|
||||
import { FilterButton } from "../ProductComponents/FilterButton";
|
||||
import { FilterTagList } from "../ProductComponents/FilterTagList";
|
||||
import { ProductsList } from "../ProductComponents/ProductsList";
|
||||
import { ProductCounter } from "../client/ProductCounter";
|
||||
import { SortDropdown } from "../client/SortDropdown";
|
||||
import { useProductFilters } from "../hooks/useProductFilters";
|
||||
|
||||
interface ProductGridWithTranslationProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductGridWithTranslation({ products }: ProductGridWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<Heading level={2} className="mb-8">{t('products.title')}</Heading>
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
filterText={t('products.filters.button')}
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter tags and product count */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<FilterTagList
|
||||
filters={activeFilters}
|
||||
formatTag={formatFilterTag}
|
||||
onRemove={removeFilter}
|
||||
onClearAll={clearAllFilters}
|
||||
clearAllText={t('products.filters.clearAll')}
|
||||
/>
|
||||
<ProductCounter count={sortedProducts.length} />
|
||||
</div>
|
||||
|
||||
{/* Products grid */}
|
||||
<ProductsList products={sortedProducts} />
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/products/client/ProductsPageContent.tsx
Normal file
31
components/products/client/ProductsPageContent.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { ProductGridWithTranslation } from "./ProductGridWithTranslation";
|
||||
|
||||
interface ProductsPageContentProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductsPageContent({ products }: ProductsPageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// If no products, display a message
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<Section>
|
||||
<Heading level={1} align="center" className="mb-4">{t('products.title')}</Heading>
|
||||
<Text className="text-center">{t('products.emptyMessage')}</Text>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section spacing="xs">
|
||||
<ProductGridWithTranslation products={products} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
117
components/products/client/SortDropdown.tsx
Normal file
117
components/products/client/SortDropdown.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function SortDropdown({ value, onChange, label = "Label" }: SortDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get the currently selected option's display text
|
||||
const getSelectedOptionText = () => {
|
||||
switch(value) {
|
||||
case 'newest': return t('products.sort.newest');
|
||||
case 'price-asc': return t('products.sort.priceAsc');
|
||||
case 'price-desc': return t('products.sort.priceDesc');
|
||||
case 'name-asc': return t('products.sort.nameAsc');
|
||||
case 'name-desc': return t('products.sort.nameDesc');
|
||||
default: return t('products.sort.title');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleSelect = (value: string) => {
|
||||
onChange(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Label */}
|
||||
<div className="text-sm text-gray-500 mb-1">{label}</div>
|
||||
|
||||
{/* Dropdown button */}
|
||||
<div
|
||||
className="flex items-center justify-between border border-gray-300 rounded px-4 py-2 bg-white cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="text-gray-800">{getSelectedOptionText()}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded shadow-lg">
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'default' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('default')}
|
||||
>
|
||||
{t('products.sort.title')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'newest' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('newest')}
|
||||
>
|
||||
{t('products.sort.newest')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'price-asc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('price-asc')}
|
||||
>
|
||||
{t('products.sort.priceAsc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'price-desc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('price-desc')}
|
||||
>
|
||||
{t('products.sort.priceDesc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'name-asc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('name-asc')}
|
||||
>
|
||||
{t('products.sort.nameAsc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'name-desc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('name-desc')}
|
||||
>
|
||||
{t('products.sort.nameDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/products/hooks/useProductFilters.ts
Normal file
69
components/products/hooks/useProductFilters.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { filterAndSortProducts, filterUtils } from "../utils/productHelpers";
|
||||
|
||||
/**
|
||||
* Custom hook for managing product filtering and sorting state
|
||||
*/
|
||||
export function useProductFilters(products: Product[]) {
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
const [sortOption, setSortOption] = useState('default');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Load filters from URL on initial render
|
||||
useEffect(() => {
|
||||
const newActiveFilters = filterUtils.getFiltersFromUrl(searchParams as URLSearchParams);
|
||||
const sort = searchParams.get('sort');
|
||||
|
||||
// Set active filters if there are any
|
||||
if (newActiveFilters.length > 0) {
|
||||
setActiveFilters(newActiveFilters);
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
setSortOption(sort);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleApplyFilters = (filters: string[]) => {
|
||||
setActiveFilters(filters);
|
||||
filterUtils.updateUrlParams(filters, sortOption);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortOption: string) => {
|
||||
setSortOption(newSortOption);
|
||||
filterUtils.updateUrlParams(activeFilters, newSortOption);
|
||||
};
|
||||
|
||||
const removeFilter = (filter: string) => {
|
||||
let newFilters: string[];
|
||||
|
||||
if (filter.startsWith('Cijena:')) {
|
||||
newFilters = activeFilters.filter(f => !f.startsWith('Cijena:'));
|
||||
} else {
|
||||
newFilters = activeFilters.filter(f => f !== filter);
|
||||
}
|
||||
|
||||
setActiveFilters(newFilters);
|
||||
filterUtils.updateUrlParams(newFilters, sortOption);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters([]);
|
||||
filterUtils.updateUrlParams([], sortOption);
|
||||
};
|
||||
|
||||
const sortedProducts = filterAndSortProducts(products, activeFilters, sortOption);
|
||||
|
||||
return {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag: filterUtils.formatFilterTag
|
||||
};
|
||||
}
|
||||
81
components/products/utils/colorUtils.ts
Normal file
81
components/products/utils/colorUtils.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
/**
|
||||
* Color mapping from color names to hex codes
|
||||
*/
|
||||
export const colorHexMap: Record<string, string> = {
|
||||
'red': '#FF0000',
|
||||
'blue': '#0000FF',
|
||||
'green': '#00FF00',
|
||||
'black': '#000000',
|
||||
'white': '#FFFFFF',
|
||||
'yellow': '#FFFF00',
|
||||
'purple': '#800080',
|
||||
'pink': '#FFC0CB',
|
||||
'orange': '#FFA500',
|
||||
'gray': '#808080',
|
||||
'crvena': '#FF0000',
|
||||
'plava': '#0000FF',
|
||||
'zelena': '#00FF00',
|
||||
'crna': '#000000',
|
||||
'bijela': '#FFFFFF',
|
||||
'žuta': '#FFFF00',
|
||||
'ljubičasta': '#800080',
|
||||
'roza': '#FFC0CB',
|
||||
'narančasta': '#FFA500',
|
||||
'siva': '#808080'
|
||||
};
|
||||
|
||||
export type ColorVariant = {
|
||||
color: string; // Hex code
|
||||
variantId: string;
|
||||
price: number;
|
||||
colorName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color options from a product if they exist
|
||||
* Returns an array of hex color codes, or undefined if no colors
|
||||
*/
|
||||
export function getProductColors(product: Product) {
|
||||
// Get colors directly from variants
|
||||
const colorVariants = getProductColorVariants(product);
|
||||
if (!colorVariants) return undefined;
|
||||
|
||||
// Return just the color codes
|
||||
return colorVariants.map(variant => variant.color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed color variants including price and variant ID
|
||||
* Extracts directly from the variants array
|
||||
*/
|
||||
export function getProductColorVariants(product: Product): ColorVariant[] | undefined {
|
||||
if (!product?.variants || product.variants.length === 0) return undefined;
|
||||
|
||||
const result: ColorVariant[] = [];
|
||||
|
||||
// Iterate through all variants looking for color options
|
||||
product.variants.forEach(variant => {
|
||||
// Find any color option in the selectedOptions
|
||||
const colorSelectedOption = variant.selectedOptions?.find(
|
||||
option => option.name.toLowerCase() === 'color' ||
|
||||
option.name.toLowerCase() === 'colour' ||
|
||||
option.name.toLowerCase() === 'boja'
|
||||
);
|
||||
|
||||
if (colorSelectedOption) {
|
||||
const colorName = colorSelectedOption.value;
|
||||
const colorHex = colorHexMap[colorName.toLowerCase()] || colorName;
|
||||
|
||||
result.push({
|
||||
color: colorHex,
|
||||
variantId: variant.id,
|
||||
price: parseFloat(variant.price.amount),
|
||||
colorName: colorName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
177
components/products/utils/productHelpers.ts
Normal file
177
components/products/utils/productHelpers.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
/**
|
||||
* Sorts products based on the selected option
|
||||
*/
|
||||
export function sortProducts(products: Product[], sortOption: string) {
|
||||
const productsCopy = [...products];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'price-asc':
|
||||
return productsCopy.sort((a, b) =>
|
||||
parseFloat(a.priceRange.maxVariantPrice.amount) - parseFloat(b.priceRange.maxVariantPrice.amount)
|
||||
);
|
||||
case 'price-desc':
|
||||
return productsCopy.sort((a, b) =>
|
||||
parseFloat(b.priceRange.maxVariantPrice.amount) - parseFloat(a.priceRange.maxVariantPrice.amount)
|
||||
);
|
||||
case 'newest':
|
||||
return productsCopy.sort((a, b) => a.handle.localeCompare(b.handle));
|
||||
case 'name-asc':
|
||||
return productsCopy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
case 'name-desc':
|
||||
return productsCopy.sort((a, b) => b.title.localeCompare(a.title));
|
||||
default:
|
||||
return productsCopy;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters products based on active filters and sorts them
|
||||
*/
|
||||
export function filterAndSortProducts(products: Product[], activeFilters: string[], sortOption: string) {
|
||||
// Filter products based on active filters
|
||||
const filteredProducts = products.filter(product => {
|
||||
if (activeFilters.length === 0) return true;
|
||||
|
||||
// Check for price filter
|
||||
const priceFilter = activeFilters.find(filter => filter.startsWith('Cijena:'));
|
||||
if (priceFilter) {
|
||||
// Extract min and max prices from filter string (format: "Cijena: 5€ - 100€")
|
||||
const priceMatch = priceFilter.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
const minPrice = parseInt(priceMatch[1]);
|
||||
const maxPrice = parseInt(priceMatch[2]);
|
||||
const productPrice = parseFloat(product.priceRange.maxVariantPrice.amount);
|
||||
|
||||
// If product price is outside the filter range, exclude it
|
||||
if (productPrice < minPrice || productPrice > maxPrice) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, we're only implementing price filtering
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort filtered products
|
||||
return sortProducts(filteredProducts, sortOption);
|
||||
}
|
||||
|
||||
// URL parameter constants
|
||||
const PARAM_FILTERS = 'filters';
|
||||
const PARAM_MIN_PRICE = 'min_price';
|
||||
const PARAM_MAX_PRICE = 'max_price';
|
||||
const PARAM_SORT = 'sort';
|
||||
const PRICE_PREFIX = 'Cijena:';
|
||||
|
||||
/**
|
||||
* Functions for handling filter tags
|
||||
*/
|
||||
export const filterUtils = {
|
||||
// Format filter tag for display
|
||||
formatFilterTag: (filter: string) => {
|
||||
// If it's a price filter, show as is
|
||||
if (filter.startsWith(PRICE_PREFIX)) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// For other filters, split by colon and show in format "Category: Option"
|
||||
const parts = filter.split(':');
|
||||
if (parts.length > 1) {
|
||||
return `${parts[0]}: ${parts[1]}`;
|
||||
}
|
||||
|
||||
// Fallback for any non-formatted filters
|
||||
return filter;
|
||||
},
|
||||
|
||||
extractPriceFilter: (priceFilter: string) => {
|
||||
const priceMatch = priceFilter?.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
return {
|
||||
minPrice: priceMatch[1],
|
||||
maxPrice: priceMatch[2]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Create a price filter string from min and max value
|
||||
createPriceFilter: (minPrice: string, maxPrice: string) => {
|
||||
return `${PRICE_PREFIX} ${minPrice}€ - ${maxPrice}€`;
|
||||
},
|
||||
|
||||
// Separate price and non-price filters
|
||||
separateFilters: (filters: string[]) => {
|
||||
const priceFilter = filters.find(f => f.startsWith(PRICE_PREFIX));
|
||||
const nonPriceFilters = filters.filter(f => !f.startsWith(PRICE_PREFIX));
|
||||
return { priceFilter, nonPriceFilters };
|
||||
},
|
||||
|
||||
encodeNonPriceFilters: (nonPriceFilters: string[]) => {
|
||||
return nonPriceFilters.length > 0
|
||||
? encodeURIComponent(JSON.stringify(nonPriceFilters))
|
||||
: '';
|
||||
},
|
||||
|
||||
decodeNonPriceFilters: (encodedFilters: string | null) => {
|
||||
if (!encodedFilters) return [];
|
||||
|
||||
try {
|
||||
const decodedFilters = JSON.parse(decodeURIComponent(encodedFilters));
|
||||
return Array.isArray(decodedFilters) ? decodedFilters : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing filters from URL', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
updateUrlParams: (filters: string[], sort: string) => {
|
||||
const params = new URLSearchParams();
|
||||
const { priceFilter, nonPriceFilters } = filterUtils.separateFilters(filters);
|
||||
|
||||
// Add non-price filters
|
||||
const encodedFilters = filterUtils.encodeNonPriceFilters(nonPriceFilters);
|
||||
if (encodedFilters) {
|
||||
params.set(PARAM_FILTERS, encodedFilters);
|
||||
}
|
||||
|
||||
// Add price filter
|
||||
if (priceFilter) {
|
||||
const priceValues = filterUtils.extractPriceFilter(priceFilter);
|
||||
if (priceValues) {
|
||||
params.set(PARAM_MIN_PRICE, priceValues.minPrice);
|
||||
params.set(PARAM_MAX_PRICE, priceValues.maxPrice);
|
||||
}
|
||||
}
|
||||
|
||||
if (sort !== 'default') {
|
||||
params.set(PARAM_SORT, sort);
|
||||
}
|
||||
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.pushState({}, '', newUrl);
|
||||
},
|
||||
|
||||
getFiltersFromUrl: (searchParams: URLSearchParams) => {
|
||||
const encodedFilters = searchParams.get(PARAM_FILTERS);
|
||||
const minPrice = searchParams.get(PARAM_MIN_PRICE);
|
||||
const maxPrice = searchParams.get(PARAM_MAX_PRICE);
|
||||
|
||||
// Start with decoded non-price filters
|
||||
const nonPriceFilters = filterUtils.decodeNonPriceFilters(encodedFilters);
|
||||
|
||||
// Add price filter if present
|
||||
if (minPrice && maxPrice) {
|
||||
const priceFilter = filterUtils.createPriceFilter(minPrice, maxPrice);
|
||||
return [...nonPriceFilters, priceFilter];
|
||||
}
|
||||
|
||||
return nonPriceFilters;
|
||||
}
|
||||
}
|
||||
15
components/prose.tsx
Normal file
15
components/prose.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
const Prose = ({ html, className }: { html: string; className?: string }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Prose;
|
||||
77
components/ui/AddToCartButton.tsx
Normal file
77
components/ui/AddToCartButton.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { addItem } from "components/cart/actions";
|
||||
import { useCart } from "components/cart/cart-context";
|
||||
import { Product, ProductVariant } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface AddToCartButtonProps {
|
||||
product: Product;
|
||||
className?: string;
|
||||
variantId?: string;
|
||||
}
|
||||
|
||||
export function AddToCartButton({ product, className = "", variantId }: AddToCartButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addCartItem } = useCart();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Get the selected variant or default to the first variant
|
||||
let selectedVariant: ProductVariant | undefined;
|
||||
|
||||
if (variantId) {
|
||||
selectedVariant = product.variants.find(v => v.id === variantId);
|
||||
}
|
||||
|
||||
// If no specified variant or variant not found, use the first one
|
||||
if (!selectedVariant) {
|
||||
selectedVariant = product.variants[0];
|
||||
}
|
||||
|
||||
if (!selectedVariant || !selectedVariant.id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add to backend cart
|
||||
await addItem(null, selectedVariant.id, 1);
|
||||
|
||||
// Update local cart state
|
||||
addCartItem(selectedVariant, product, 1);
|
||||
|
||||
// Set success message or notification here if needed
|
||||
} catch (error) {
|
||||
console.error("Failed to add item to cart:", error);
|
||||
// Handle error (could show error message)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isLoading || !product.availableForSale}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className={`w-full ${className}`}
|
||||
>
|
||||
{isLoading
|
||||
? t('product.adding')
|
||||
: !product.availableForSale
|
||||
? t('product.outOfStock')
|
||||
: t('product.addToCart')
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
74
components/ui/Button.tsx
Normal file
74
components/ui/Button.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'outline' | 'primary' | 'secondary' | 'filled' | 'custom';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
fullWidthMobile?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className = '', variant = 'default', size = 'md', children, href, external, fullWidthMobile, fullWidth, ...props }, ref) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-white text-black border border-gray-200 hover:bg-gray-100',
|
||||
outline: 'bg-transparent border border-gray-300 hover:bg-gray-100',
|
||||
primary: 'bg-primary text-white hover:bg-primary-dark',
|
||||
secondary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
filled: 'bg-primary text-white hover:bg-primary-dark',
|
||||
custom: 'bg-[#f8dbe3] text-primary hover:bg-primary hover:text-white border-0',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2',
|
||||
lg: 'px-[24px] py-[12px] text-lg',
|
||||
};
|
||||
|
||||
const widthClass = fullWidth
|
||||
? 'w-full'
|
||||
: fullWidthMobile
|
||||
? 'w-full md:w-auto'
|
||||
: '';
|
||||
|
||||
const buttonClasses = `
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${widthClass}
|
||||
${className}
|
||||
rounded-button font-bold transition-colors focus:outline-none inline-flex justify-center items-center
|
||||
`;
|
||||
|
||||
if (href) {
|
||||
const linkProps = external ? {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
{...linkProps}
|
||||
className={buttonClasses}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={buttonClasses}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
76
components/ui/Card.tsx
Normal file
76
components/ui/Card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user