chore: transfer repo

This commit is contained in:
Danijel
2026-01-19 20:21:14 +01:00
commit 7d2fb0c737
213 changed files with 18085 additions and 0 deletions

34
components/Label.tsx Normal file
View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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 />;
}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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}:&nbsp;
<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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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.'
};
}
}

View 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>
);
}

View 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;
}

View 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
};
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
</>
);
}

View 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>
);
}

View 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
View 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;

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
))}
</>
);
}

View 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>
);
}

View 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>&copy; {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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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} />;
}

View 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>
);
}

View 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
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
));
}

View 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>
);
}

View 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 });
};
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
};
}

View 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;
}

View 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
View 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;

View 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
View 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
View 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