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

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