chore: transfer repo
This commit is contained in:
173
components/build-box/AddBoxToCartClient.tsx
Normal file
173
components/build-box/AddBoxToCartClient.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { clearBox, selectBoxItems, selectEditingBoxGroupId } from "@/lib/redux/slices/boxSlice";
|
||||
import { addItem, removeItem } from "components/cart/actions";
|
||||
import { useCart } from "components/cart/cart-context";
|
||||
import { Product, ProductVariant } from "lib/shopify/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function AddBoxToCartClient() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(true);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [boxProducts, setBoxProducts] = useState<Product[]>([]);
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const editingBoxGroupId = useAppSelector(selectEditingBoxGroupId);
|
||||
const { addCartItem, cart } = useCart();
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch products on mount
|
||||
useEffect(() => {
|
||||
async function fetchProducts() {
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
// Fetch regular products
|
||||
const regularResponse = await fetch('/api/products');
|
||||
const regularData = await regularResponse.json();
|
||||
|
||||
// Fetch box products
|
||||
const boxesResponse = await fetch('/api/products?type=boxes');
|
||||
const boxesData = await boxesResponse.json();
|
||||
|
||||
if (regularData.products) setAllProducts(regularData.products);
|
||||
if (boxesData.products) setBoxProducts(boxesData.products);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
// Check if we have a box
|
||||
const boxItems = items.filter(item => item.variantId === 'box-container');
|
||||
const productItems = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
const isEnabled = boxItems.length > 0 && !isLoadingProducts;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (isLoading || !isEnabled) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Save the current box state to localStorage for potential editing later
|
||||
try {
|
||||
const boxState = {
|
||||
boxItems: boxItems,
|
||||
productItems: productItems,
|
||||
boxGroupId: `box-${Date.now()}`
|
||||
};
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
} catch (error) {
|
||||
console.error("Failed to save box state:", error);
|
||||
}
|
||||
|
||||
// Generate a unique box ID to group all items together
|
||||
const boxGroupId = `box-${Date.now()}`;
|
||||
|
||||
// Ako je ovo uređivanje postojećeg boxa, prvo ukloni stari box i sve njegove proizvode
|
||||
if (editingBoxGroupId && cart) {
|
||||
// Pronađi sve proizvode i kontejner koji pripadaju ovom boxu
|
||||
const itemsToRemove = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === 'box_group_id')?.value;
|
||||
return itemBoxGroupId === editingBoxGroupId;
|
||||
});
|
||||
|
||||
// Ukloni sve pronađene proizvode
|
||||
for (const item of itemsToRemove) {
|
||||
if (item.id && item.merchandise.id) {
|
||||
await removeItem(null, item.merchandise.id, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add box items first
|
||||
for (const boxItem of boxItems) {
|
||||
// Find the actual box product from our pre-loaded products
|
||||
const boxProduct = boxProducts.find(p => p.id === boxItem.id);
|
||||
|
||||
if (boxProduct) {
|
||||
// Get the variant to use
|
||||
const boxVariant = boxProduct.variants[0];
|
||||
|
||||
if (boxVariant) {
|
||||
// Add box with attribute marking it as a box container and the box group ID
|
||||
await addItem(
|
||||
null,
|
||||
boxVariant.id,
|
||||
boxItem.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "container" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Update local cart state
|
||||
addCartItem(boxVariant, boxProduct, boxItem.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add product items
|
||||
for (const productItem of productItems) {
|
||||
// Find the actual product from our pre-loaded products
|
||||
const product = allProducts.find(p => p.id === productItem.id);
|
||||
|
||||
if (product) {
|
||||
// Find the variant
|
||||
let selectedVariant: ProductVariant | undefined;
|
||||
|
||||
if (productItem.variantId && productItem.variantId !== 'undefined') {
|
||||
selectedVariant = product.variants.find(v => v.id === productItem.variantId);
|
||||
}
|
||||
|
||||
// If no variant found, use the first one (exactly like AddToCartButton.tsx)
|
||||
if (!selectedVariant) {
|
||||
selectedVariant = product.variants[0];
|
||||
}
|
||||
|
||||
if (selectedVariant && selectedVariant.id) {
|
||||
// Add product with attribute marking it as a box item and the box group ID
|
||||
await addItem(
|
||||
null,
|
||||
selectedVariant.id,
|
||||
productItem.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "item" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Update local cart state
|
||||
addCartItem(selectedVariant, product, productItem.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(clearBox());
|
||||
} catch (error) {
|
||||
console.error("Failed to add box to cart:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isLoading || !isEnabled}
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
>
|
||||
{isLoading ? "Dodaje se..." : isLoadingProducts ? "Učitavanje..." : "Dodaj u košaricu"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
53
components/build-box/BuildBoxClientPage.tsx
Normal file
53
components/build-box/BuildBoxClientPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Text } from "@/components/ui/Typography";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import { container } from "@/lib/utils";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import Link from "next/link";
|
||||
import { ProductGrid } from "../products/ProductGrid";
|
||||
import { BuildBoxSidebar } from "./BuildBoxSidebar";
|
||||
|
||||
interface BuildBoxClientPageProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function BuildBoxClientPage({ products }: BuildBoxClientPageProps) {
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Main content */}
|
||||
<div className={`${container} lg:pr-[240px]`}>
|
||||
|
||||
<ProductGrid products={products} title="" />
|
||||
|
||||
{/* Add bottom padding on mobile to make room for mobile sidebar */}
|
||||
<div className="h-24 lg:hidden"></div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - right side, full height with scrolling */}
|
||||
<div className="hidden lg:block fixed top-0 right-0 w-[230px] h-full bg-gray-100 border-l border-gray-200 shadow-sm">
|
||||
<div className="pt-[88px] h-full overflow-y-auto">
|
||||
<BuildBoxSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - fixed at bottom of screen */}
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
|
||||
<div>
|
||||
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
<Link
|
||||
href="/build-box/customize"
|
||||
className="bg-primary text-white px-6 py-2 rounded disabled:bg-gray-400"
|
||||
aria-disabled={boxTotal === 0}
|
||||
tabIndex={boxTotal === 0 ? -1 : undefined}
|
||||
>
|
||||
Idući korak
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal file
119
components/build-box/BuildBoxCustomizeClient.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Box option interface
|
||||
export interface BoxOption {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface BoxCustomizeProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizeClient({ boxes }: BoxCustomizeProps) {
|
||||
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
|
||||
// Check if there's already a box in the items and set it as selected on load
|
||||
useEffect(() => {
|
||||
const existingBox = items.find(item => item.variantId === 'box-container');
|
||||
if (existingBox) {
|
||||
setSelectedBoxId(existingBox.id);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
// When a box is selected, replace any existing box with the new one
|
||||
const handleSelectBox = (box: BoxOption) => {
|
||||
// If the same box is already selected, do nothing
|
||||
if (selectedBoxId === box.id) return;
|
||||
|
||||
// Set this box as selected
|
||||
setSelectedBoxId(box.id);
|
||||
|
||||
// Remove any existing box containers first
|
||||
const existingBoxes = items.filter(item => item.variantId === 'box-container');
|
||||
existingBoxes.forEach(existingBox => {
|
||||
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
|
||||
});
|
||||
|
||||
// Add the new box
|
||||
dispatch(addToBox({
|
||||
id: box.id,
|
||||
name: box.title,
|
||||
price: box.price,
|
||||
image: box.image,
|
||||
quantity: 1,
|
||||
variantId: 'box-container', // Mark this as a box container, not a product
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{boxes.map((box) => (
|
||||
<BoxOption
|
||||
key={box.id}
|
||||
box={box}
|
||||
isSelected={selectedBoxId === box.id}
|
||||
onSelect={() => handleSelectBox(box)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Box option component
|
||||
function BoxOption({
|
||||
box,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
box: BoxOption;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`border rounded-md overflow-hidden ${isSelected ? 'ring-2 ring-primary' : ''}`}>
|
||||
<div className="relative h-60 overflow-hidden">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300"
|
||||
/>
|
||||
{/* Overlay with "Dodaj u box" button - always visible for selected, only on hover for others */}
|
||||
<div className={`absolute inset-0 flex items-center justify-center ${isSelected ? 'bg-black/40' : 'bg-black/40 opacity-0 hover:opacity-100 transition-opacity duration-300'}`}>
|
||||
<Button
|
||||
variant="custom"
|
||||
onClick={onSelect}
|
||||
>
|
||||
Dodaj u box
|
||||
</Button>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary text-white rounded-full p-1 w-6 h-6 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-medium">{box.title}</h3>
|
||||
<span className="font-medium">${box.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
components/build-box/BuildBoxCustomizePage.tsx
Normal file
21
components/build-box/BuildBoxCustomizePage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getBoxProducts } from "lib/shopify";
|
||||
import { BoxOption } from "./BuildBoxCustomizeClient";
|
||||
import { BuildBoxCustomizePageContent } from "./client/BuildBoxCustomizePageContent";
|
||||
|
||||
export async function BuildBoxCustomizePage() {
|
||||
// Fetch boxes from the API
|
||||
const boxes = await getBoxProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
// Convert products to box options for display
|
||||
const boxOptions: BoxOption[] = boxes.map(box => ({
|
||||
id: box.id,
|
||||
title: box.title,
|
||||
price: parseFloat(box.priceRange.minVariantPrice.amount),
|
||||
image: box.featuredImage?.url || '/placeholder-box.jpg'
|
||||
}));
|
||||
|
||||
return <BuildBoxCustomizePageContent boxes={boxOptions} />;
|
||||
}
|
||||
24
components/build-box/BuildBoxMobileSummary.tsx
Normal file
24
components/build-box/BuildBoxMobileSummary.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Text } from "@/components/ui/Typography";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import Link from "next/link";
|
||||
|
||||
export function BuildBoxMobileSummary() {
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 h-20 bg-white border-t border-gray-200 flex items-center justify-between px-4 z-50">
|
||||
<div>
|
||||
<Text weight="semibold">Cijena boxa: ${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
<Link
|
||||
href="/build-box/customize"
|
||||
className={`px-6 py-2 rounded ${boxTotal > 0 ? 'bg-black text-white' : 'bg-gray-300 text-gray-500 pointer-events-none'}`}
|
||||
>
|
||||
Idući korak
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
components/build-box/BuildBoxPage.tsx
Normal file
12
components/build-box/BuildBoxPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getRegularProducts } from "lib/shopify";
|
||||
import { BuildBoxPageContent } from "./client/BuildBoxPageContent";
|
||||
|
||||
// Main server component
|
||||
export async function BuildBoxPage() {
|
||||
const products = await getRegularProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
return <BuildBoxPageContent products={products} />;
|
||||
}
|
||||
203
components/build-box/BuildBoxSidebar.tsx
Normal file
203
components/build-box/BuildBoxSidebar.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AddBoxToCartClient } from "./AddBoxToCartClient";
|
||||
|
||||
export function BuildBoxSidebar() {
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
const isEmpty = items.length === 0;
|
||||
const pathname = usePathname();
|
||||
|
||||
// Separate box containers from products
|
||||
const boxContainers = items.filter(item => item.variantId === 'box-container');
|
||||
const products = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
// Check if we have products for the first step
|
||||
const hasProducts = products.length > 0;
|
||||
|
||||
// Check if we have a selected box for the second step
|
||||
const hasBoxSelected = boxContainers.length > 0;
|
||||
|
||||
// Determine which page we're on to show appropriate button text
|
||||
const isCustomizePage = pathname.includes('/customize');
|
||||
const nextStepUrl = "/build-box/customize";
|
||||
const nextStepText = "Idući korak";
|
||||
|
||||
// Determine if the next step button should be disabled
|
||||
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
|
||||
|
||||
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string, color: string | undefined) => {
|
||||
dispatch(removeFromBox({ id, color }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Fixed header - adjusted padding to align with main page title */}
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">Your box</Heading>
|
||||
</div>
|
||||
|
||||
{/* Scrollable products area */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>Your box is empty</Text>
|
||||
<Text className="text-sm mt-2">Add products to create your box</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Show box container if any */}
|
||||
{boxContainers.length > 0 && (
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<Text className="font-medium mb-2">Box Design</Text>
|
||||
{boxContainers.map((box: BoxItem) => (
|
||||
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">{box.name}</div>
|
||||
<div className="text-sm text-gray-500">${box.price}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(box.id, box.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove box"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product items */}
|
||||
{products.length > 0 && (
|
||||
<>
|
||||
{products.map((item: BoxItem) => (
|
||||
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-between min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<div className="max-w-[75%]">
|
||||
<Text className="text-base font-medium break-words">{item.name}</Text>
|
||||
<div className="flex items-center mt-1">
|
||||
<Text className="text-sm text-gray-500">${item.price}</Text>
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full ml-2"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-label="Color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id, item.color)}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
|
||||
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
|
||||
disabled={item.quantity <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
|
||||
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed price and button at bottom */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<Text className="font-medium">Cijena boxa</Text>
|
||||
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
|
||||
{isCustomizePage ? (
|
||||
<AddBoxToCartClient />
|
||||
) : (
|
||||
<Link
|
||||
href={isNextStepDisabled ? "#" : nextStepUrl}
|
||||
className={isNextStepDisabled ? "pointer-events-none" : ""}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
{nextStepText}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isCustomizePage && !hasProducts && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
Add at least one product to proceed
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isCustomizePage && !hasBoxSelected && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
Select a box design to continue
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
components/build-box/ClientSidebarWrapper.tsx
Normal file
49
components/build-box/ClientSidebarWrapper.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BuildBoxSidebar } from "./BuildBoxSidebar";
|
||||
|
||||
export function ClientSidebarWrapper() {
|
||||
// Use state to prevent hydration errors
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// After component mounts, set isClient to true
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// On the server, or during hydration, return a placeholder with the same dimensions
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Section spacing="xs" className="border-b">
|
||||
<Heading level={3} className="text-center">Your box</Heading>
|
||||
</Section>
|
||||
<div className="flex-1 p-4">
|
||||
{/* Placeholder content to match dimensions */}
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="font-medium">Cijena boxa</p>
|
||||
<p className="font-medium">$0.00</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On the client, after hydration, render the actual sidebar
|
||||
return <BuildBoxSidebar />;
|
||||
}
|
||||
24
components/build-box/RedirectIfEmptyBox.tsx
Normal file
24
components/build-box/RedirectIfEmptyBox.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function RedirectIfEmptyBox() {
|
||||
const router = useRouter();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMounted && items.length === 0) {
|
||||
router.replace('/build-box');
|
||||
}
|
||||
}, [items, router, isMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { clearBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import { addItem } from "components/cart/actions";
|
||||
import { useCart } from "components/cart/cart-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AddBoxToCartClientWithTranslationProps {
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function AddBoxToCartClientWithTranslation({ buttonText }: AddBoxToCartClientWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const boxItems = useAppSelector(selectBoxItems);
|
||||
const router = useRouter();
|
||||
const { addCartItem } = useCart();
|
||||
|
||||
// Check if we have a box container
|
||||
const hasBoxContainer = boxItems.some(item => item.variantId === 'box-container');
|
||||
|
||||
// Disable the button if no box container is selected
|
||||
const isDisabled = !hasBoxContainer;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get box container details (for image, name etc.)
|
||||
const boxContainer = boxItems.find(item => item.variantId === 'box-container');
|
||||
|
||||
if (!boxContainer) {
|
||||
throw new Error('No box container selected');
|
||||
}
|
||||
|
||||
// Generate a unique box ID to group all items together
|
||||
const boxGroupId = `box-${Date.now()}`;
|
||||
|
||||
// Add box to cart with attribute marking it as a box container
|
||||
await addItem(
|
||||
null,
|
||||
boxContainer.id,
|
||||
1,
|
||||
[
|
||||
{ key: "_box_type", value: "container" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
|
||||
// Add all product items with same box group ID
|
||||
for (const item of boxItems.filter(i => i.variantId !== 'box-container')) {
|
||||
await addItem(
|
||||
null,
|
||||
item.id,
|
||||
item.quantity,
|
||||
[
|
||||
{ key: "_box_type", value: "item" },
|
||||
{ key: "_box_group_id", value: boxGroupId }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the box
|
||||
dispatch(clearBox());
|
||||
|
||||
// Redirect to cart page
|
||||
router.push('/cart');
|
||||
} catch (error) {
|
||||
console.error('Error adding box to cart:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddToCart}
|
||||
className="w-full py-3"
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{isLoading ? 'Loading...' : (buttonText || t('buildBox.customize.addToCart'))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { addToBox, removeFromBox, selectBoxItems } from "@/lib/redux/slices/boxSlice";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface BoxOption {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface BuildBoxCustomizeClientWithTranslationProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizeClientWithTranslation({ boxes }: BuildBoxCustomizeClientWithTranslationProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedBox, setSelectedBox] = useState<string | null>(null);
|
||||
const boxItems = useAppSelector(selectBoxItems);
|
||||
|
||||
// Učitaj odabranu kutiju iz postojećih item-a prilikom učitavanja stranice
|
||||
useEffect(() => {
|
||||
const existingBox = boxItems.find(item => item.variantId === 'box-container');
|
||||
if (existingBox) {
|
||||
setSelectedBox(existingBox.id);
|
||||
}
|
||||
}, [boxItems]);
|
||||
|
||||
const handleSelectBox = (box: BoxOption) => {
|
||||
// Ako je ista kutija već odabrana, ne radi ništa
|
||||
if (selectedBox === box.id) return;
|
||||
|
||||
setSelectedBox(box.id);
|
||||
|
||||
// Ukloni sve postojeće kutije iz košarice
|
||||
const existingBoxes = boxItems.filter(item => item.variantId === 'box-container');
|
||||
existingBoxes.forEach(existingBox => {
|
||||
dispatch(removeFromBox({ id: existingBox.id, color: existingBox.color }));
|
||||
});
|
||||
|
||||
// Add box to the cart
|
||||
dispatch(addToBox({
|
||||
id: box.id,
|
||||
name: box.title,
|
||||
price: box.price,
|
||||
image: box.image,
|
||||
quantity: 1,
|
||||
variantId: 'box-container', // Special ID to identify this as a box
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{boxes.map((box) => {
|
||||
const isSelected = selectedBox === box.id;
|
||||
return (
|
||||
<div
|
||||
key={box.id}
|
||||
className={`flex flex-col group relative cursor-pointer ${isSelected ? 'ring-2 ring-primary rounded-lg' : ''}`}
|
||||
onClick={() => handleSelectBox(box)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{/* Oznaka za odabranu kutiju */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 right-0 bg-primary text-white m-2 rounded-full p-1 w-6 h-6 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile button - always visible */}
|
||||
<div className="absolute inset-0 flex items-center justify-center md:hidden">
|
||||
<Button
|
||||
variant={isSelected ? "custom" : "primary"}
|
||||
className="w-auto shadow-md"
|
||||
>
|
||||
{isSelected
|
||||
? (locale === 'en' ? 'Selected' : 'Odabrano')
|
||||
: t('buildBox.customize.options.select')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop button - visible on hover */}
|
||||
<div className="absolute inset-0 hidden md:flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<Button
|
||||
variant={isSelected ? "custom" : "primary"}
|
||||
className="w-auto shadow-md"
|
||||
>
|
||||
{isSelected
|
||||
? (locale === 'en' ? 'Selected' : 'Odabrano')
|
||||
: t('buildBox.customize.options.select')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col">
|
||||
<h3 className="text-lg font-bold">{box.title}</h3>
|
||||
<p className="text-lg font-medium">${box.price.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal file
47
components/build-box/client/BuildBoxCustomizePageContent.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import Link from "next/link";
|
||||
import { BoxOption } from "../BuildBoxCustomizeClient";
|
||||
import { RedirectIfEmptyBox } from "../RedirectIfEmptyBox";
|
||||
import { BuildBoxCustomizeClientWithTranslation } from "./BuildBoxCustomizeClientWithTranslation";
|
||||
import { BuildBoxLayout } from "./BuildBoxLayout";
|
||||
|
||||
interface BuildBoxCustomizePageContentProps {
|
||||
boxes: BoxOption[];
|
||||
}
|
||||
|
||||
export function BuildBoxCustomizePageContent({ boxes }: BuildBoxCustomizePageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Back button component
|
||||
const BackButton = (
|
||||
<Link href="/build-box" className="inline-flex items-center text-sm text-gray-600">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{t('buildBox.customize.back')}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// If no boxes, display a message
|
||||
if (!boxes || boxes.length === 0) {
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
|
||||
<div className="text-center py-8">
|
||||
<p>{t('buildBox.customize.emptyMessage')}</p>
|
||||
</div>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.customize.title')} step={2} backButton={BackButton}>
|
||||
{/* Client component to check if box has items and redirect if empty */}
|
||||
<RedirectIfEmptyBox />
|
||||
|
||||
{/* Boxes Grid - Client Component */}
|
||||
<BuildBoxCustomizeClientWithTranslation boxes={boxes} />
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
113
components/build-box/client/BuildBoxLayout.tsx
Normal file
113
components/build-box/client/BuildBoxLayout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppSelector } from "@/lib/redux/hooks";
|
||||
import { selectBoxTotal } from "@/lib/redux/slices/boxSlice";
|
||||
import { container } from "@/lib/utils";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ClientSidebarWrapperWithTranslation } from "./ClientSidebarWrapperWithTranslation";
|
||||
|
||||
interface BuildBoxLayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
step: number;
|
||||
totalSteps?: number;
|
||||
backButton?: ReactNode;
|
||||
}
|
||||
|
||||
export function BuildBoxLayout({
|
||||
children,
|
||||
title,
|
||||
step,
|
||||
totalSteps = 2,
|
||||
backButton
|
||||
}: BuildBoxLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mobileCartOpen, setMobileCartOpen] = useState(false);
|
||||
const storeBoxTotal = useAppSelector(selectBoxTotal);
|
||||
|
||||
// Dodajemo state za boxTotal koji će se koristiti za prikaz
|
||||
const [displayBoxTotal, setDisplayBoxTotal] = useState(0);
|
||||
|
||||
// Ažuriramo displayBoxTotal samo na klijentskoj strani
|
||||
useEffect(() => {
|
||||
setDisplayBoxTotal(storeBoxTotal);
|
||||
}, [storeBoxTotal]);
|
||||
|
||||
// Toggle mobile cart sidebar
|
||||
const toggleMobileCart = () => {
|
||||
setMobileCartOpen(!mobileCartOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Main content */}
|
||||
<div className={container}>
|
||||
{/* Custom header with step indicator */}
|
||||
<div className="mt-8 mb-8">
|
||||
{/* Za korak 2, gumb nazad je iznad naslova */}
|
||||
{step === 2 && backButton && (
|
||||
<div className="mb-4">
|
||||
{backButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Za korak 1, gumb nazad je pored naslova (ako postoji) */}
|
||||
{step === 1 && backButton && (
|
||||
<div>{backButton}</div>
|
||||
)}
|
||||
|
||||
<Heading level={step === 1 ? 2 : 1} className={step === 1 ? "mt-16" : ""}>
|
||||
{title}:
|
||||
<span className="text-primary">{t('buildBox.step')} {step}</span>
|
||||
</Heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pr-0 lg:pr-[240px]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Mobile sticky footer with cart button */}
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-[#E8E8E8] p-4 z-20 shadow-sm">
|
||||
<div
|
||||
className="w-full py-3 flex items-center justify-between cursor-pointer"
|
||||
onClick={toggleMobileCart}
|
||||
>
|
||||
<span className="font-medium">{t('buildBox.sidebar.title')}</span>
|
||||
<ChevronUp size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add padding at the bottom on mobile to account for the sticky footer */}
|
||||
<div className="h-20 lg:h-0"></div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar - right side, fixed position */}
|
||||
<div className="hidden lg:block fixed top-[63px] right-0 w-[230px] h-[calc(100vh-65px)] bg-gray-100 border-l border-gray-200 shadow-sm z-10">
|
||||
<ClientSidebarWrapperWithTranslation />
|
||||
</div>
|
||||
|
||||
{/* Mobile full-screen sidebar overlay */}
|
||||
{mobileCartOpen && (
|
||||
<div className="lg:hidden fixed inset-0 bg-white z-50 flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Heading level={3}>{t('buildBox.sidebar.title')}</Heading>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={toggleMobileCart}
|
||||
>
|
||||
<ChevronDown size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ClientSidebarWrapperWithTranslation isMobile={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/build-box/client/BuildBoxPageContent.tsx
Normal file
71
components/build-box/client/BuildBoxPageContent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { SortDropdown } from "@/components/products/client/SortDropdown";
|
||||
import { FilterSidebar } from "@/components/products/FilterSidebar";
|
||||
import { useProductFilters } from "@/components/products/hooks/useProductFilters";
|
||||
import { FilterButton } from "@/components/products/ProductComponents/FilterButton";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { ProductGrid } from "../../products/ProductGrid";
|
||||
import { BuildBoxLayout } from "./BuildBoxLayout";
|
||||
|
||||
interface BuildBoxPageContentProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function BuildBoxPageContent({ products }: BuildBoxPageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
// If no products, display a message
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.title')} step={1}>
|
||||
<div className="text-center py-8">
|
||||
<p>{t('buildBox.emptyMessage')}</p>
|
||||
</div>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BuildBoxLayout title={t('buildBox.title')} step={1}>
|
||||
<div className="mb-8">
|
||||
{/* Filter and Sort Controls */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton onClick={() => setSidebarOpen(true)} />
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
label={t('products.sort.title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductGrid
|
||||
products={sortedProducts}
|
||||
title=""
|
||||
showControls={false}
|
||||
/>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
</BuildBoxLayout>
|
||||
);
|
||||
}
|
||||
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal file
210
components/build-box/client/BuildBoxSidebarWithTranslation.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useAppDispatch, useAppSelector } from "@/lib/redux/hooks";
|
||||
import { BoxItem, removeFromBox, selectBoxItems, selectBoxTotal, updateQuantity } from "@/lib/redux/slices/boxSlice";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AddBoxToCartClient } from "../AddBoxToCartClient";
|
||||
|
||||
interface BuildBoxSidebarWithTranslationProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function BuildBoxSidebarWithTranslation({ isMobile = false }: BuildBoxSidebarWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector(selectBoxItems);
|
||||
const boxTotal = useAppSelector(selectBoxTotal);
|
||||
const isEmpty = items.length === 0;
|
||||
const pathname = usePathname();
|
||||
|
||||
// Separate box containers from products
|
||||
const boxContainers = items.filter(item => item.variantId === 'box-container');
|
||||
const products = items.filter(item => item.variantId !== 'box-container');
|
||||
|
||||
// Check if we have products for the first step
|
||||
const hasProducts = products.length > 0;
|
||||
|
||||
// Check if we have a selected box for the second step
|
||||
const hasBoxSelected = boxContainers.length > 0;
|
||||
|
||||
// Determine which page we're on to show appropriate button text
|
||||
const isCustomizePage = pathname.includes('/customize');
|
||||
const nextStepUrl = "/build-box/customize";
|
||||
const nextStepText = t('buildBox.sidebar.nextStep');
|
||||
|
||||
// Determine if the next step button should be disabled
|
||||
const isNextStepDisabled = isEmpty || (!isCustomizePage && !hasProducts);
|
||||
|
||||
const handleUpdateQuantity = (id: string, color: string | undefined, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
dispatch(updateQuantity({ id, color, quantity: newQuantity }));
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string, color: string | undefined) => {
|
||||
dispatch(removeFromBox({ id, color }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Fixed header - prikazan samo ako nije mobilni prikaz */}
|
||||
{!isMobile && (
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable products area */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>{t('buildBox.sidebar.empty')}</Text>
|
||||
<Text className="text-sm mt-2">{t('buildBox.sidebar.emptySubtext')}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Show box container if any */}
|
||||
{boxContainers.length > 0 && (
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<Text className="font-medium mb-2">{t('buildBox.sidebar.boxDesign')}</Text>
|
||||
{boxContainers.map((box: BoxItem) => (
|
||||
<div key={box.compositeKey || box.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={box.image}
|
||||
alt={box.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">{box.name}</div>
|
||||
<div className="text-sm text-gray-500">${box.price}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(box.id, box.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove box"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product items */}
|
||||
{products.length > 0 && (
|
||||
<>
|
||||
{products.map((item: BoxItem) => (
|
||||
<div key={item.compositeKey || item.id} className="flex items-start space-x-3">
|
||||
<div className="w-20 h-24 relative flex-shrink-0">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-between h-24">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">${item.price}</div>
|
||||
{item.color && (
|
||||
<div className="text-xs text-gray-500 mt-1 flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-1"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span>Color</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id, item.color)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity - 1)}
|
||||
className="px-2 py-1 border-r border-gray-300 text-gray-500 hover:bg-gray-100 disabled:opacity-50"
|
||||
disabled={item.quantity <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="px-3 py-1 flex items-center justify-center w-8 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.id, item.color, item.quantity + 1)}
|
||||
className="px-2 py-1 border-l border-gray-300 text-gray-500 hover:bg-gray-100"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed price and button at bottom */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<Text className="font-medium">{t('buildBox.sidebar.boxPrice')}</Text>
|
||||
<Text className="font-medium">${boxTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
|
||||
{isCustomizePage ? (
|
||||
<AddBoxToCartClient />
|
||||
) : (
|
||||
<Link
|
||||
href={isNextStepDisabled ? "#" : nextStepUrl}
|
||||
className={isNextStepDisabled ? "pointer-events-none" : ""}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full py-3"
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
{nextStepText}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isCustomizePage && !hasProducts && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
{t('buildBox.sidebar.addProductWarning')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isCustomizePage && !hasBoxSelected && !isEmpty && (
|
||||
<Text className="text-xs text-center text-red-500 mt-2">
|
||||
{t('buildBox.sidebar.selectBoxWarning')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BuildBoxSidebarWithTranslation } from "./BuildBoxSidebarWithTranslation";
|
||||
|
||||
interface ClientSidebarWrapperWithTranslationProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function ClientSidebarWrapperWithTranslation({ isMobile = false }: ClientSidebarWrapperWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use state to prevent hydration errors
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// After component mounts, set isClient to true
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// On the server, or during hydration, return a placeholder with the same dimensions
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{!isMobile && (
|
||||
<div className="p-4 pt-8 pb-4 border-b">
|
||||
<Heading level={3} className="text-center">{t('buildBox.sidebar.title')}</Heading>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 p-4">
|
||||
{/* Placeholder content to match dimensions */}
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="font-medium">{t('buildBox.sidebar.boxPrice')}</p>
|
||||
<p className="font-medium">$0.00</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-3 bg-gray-200 text-gray-500 rounded cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On the client, after hydration, render the actual sidebar
|
||||
return <BuildBoxSidebarWithTranslation isMobile={isMobile} />;
|
||||
}
|
||||
Reference in New Issue
Block a user