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