chore: transfer repo
This commit is contained in:
161
components/cart/CartBoxItem.tsx
Normal file
161
components/cart/CartBoxItem.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { useAppDispatch } from '@/lib/redux/hooks';
|
||||
import { loadBoxForEditing } from '@/lib/redux/slices/boxSlice';
|
||||
import Price from 'components/price';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { ChevronDown, ChevronUp, Pencil, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { CartProductItem } from './CartProductItem';
|
||||
import { getUniqueItemKey } from './processCartItems';
|
||||
|
||||
interface CartBoxItemProps {
|
||||
boxItem: CartItem;
|
||||
boxProducts: CartItem[];
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function CartBoxItem({ boxItem, boxProducts, onUpdate, isPending }: CartBoxItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get box attributes if any
|
||||
const boxGroupId = boxItem.attributes?.find(attr => attr.key === '_box_group_id')?.value || 'box';
|
||||
|
||||
// Handle box deletion - delete box and all products inside it
|
||||
const handleBoxDelete = () => {
|
||||
// First update localStorage to reflect the box deletion
|
||||
try {
|
||||
const boxStateString = localStorage.getItem('lastBoxState');
|
||||
if (boxStateString) {
|
||||
const boxState = JSON.parse(boxStateString);
|
||||
|
||||
// Check if this is the box that's currently saved for editing
|
||||
if (boxState.originalBoxGroupId === boxGroupId) {
|
||||
// Remove the box from localStorage
|
||||
localStorage.removeItem('lastBoxState');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating box state in localStorage on delete', error);
|
||||
}
|
||||
|
||||
// First delete the box container
|
||||
onUpdate(boxItem.merchandise.id, 'delete', boxItem.id);
|
||||
|
||||
// Then delete all products inside the box
|
||||
boxProducts.forEach(product => {
|
||||
if (product.id) {
|
||||
onUpdate(product.merchandise.id, 'delete', product.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle box editing
|
||||
const handleEditBox = () => {
|
||||
// Prvo pokušaj direktno spremiti trenutni boxGroupId u lokalno stanje
|
||||
try {
|
||||
const currentBoxState = localStorage.getItem('lastBoxState');
|
||||
let boxStateObject = currentBoxState ? JSON.parse(currentBoxState) : {};
|
||||
|
||||
// Dodaj originalni boxGroupId da kasnije znamo što trebamo obrisati
|
||||
boxStateObject.originalBoxGroupId = boxGroupId;
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxStateObject));
|
||||
} catch (error) {
|
||||
console.error('Failed to save original box group ID', error);
|
||||
}
|
||||
|
||||
// Attempt to load box state for editing from localStorage
|
||||
dispatch(loadBoxForEditing());
|
||||
|
||||
// Redirect to build-box page
|
||||
router.push('/build-box');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
{/* Box Header */}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center">
|
||||
{/* Box Image */}
|
||||
<div className="relative h-24 w-24 flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={boxItem.merchandise.product.featuredImage?.url || ''}
|
||||
alt={boxItem.merchandise.product.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Box Details */}
|
||||
<div className="pl-4">
|
||||
<Heading level={4}>
|
||||
{boxItem.merchandise.product.title}
|
||||
</Heading>
|
||||
<Price
|
||||
amount={boxItem.cost.totalAmount.amount}
|
||||
currencyCode={boxItem.cost.totalAmount.currencyCode}
|
||||
className="text-sm mt-1 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Actions */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
onClick={handleBoxDelete}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
|
||||
disabled={isPending}
|
||||
aria-label={t('cart.remove')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleEditBox}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('cart.editBox')}
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-gray-700"
|
||||
aria-label={isExpanded ? t('cart.collapseBox') : t('cart.expandBox')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Products */}
|
||||
{isExpanded && boxProducts.length > 0 && (
|
||||
<div className="space-y-4 py-2 pb-4">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">{t('cart.customBoxContents')}</h4>
|
||||
{boxProducts.map((item, index) => (
|
||||
<CartProductItem
|
||||
key={getUniqueItemKey(item, boxGroupId, index)}
|
||||
item={item}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
isInBox={true}
|
||||
boxGroupId={boxGroupId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
components/cart/CartDiscountForm.tsx
Normal file
84
components/cart/CartDiscountForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { FormEvent, useState, useTransition } from 'react';
|
||||
import { validateDiscountCode } from './actions';
|
||||
|
||||
export function CartDiscountForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [couponMessage, setCouponMessage] = useState<{ text: string; isValid: boolean } | null>(null);
|
||||
|
||||
// Handle coupon validation
|
||||
const handleCouponSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!couponCode.trim()) {
|
||||
setCouponMessage({
|
||||
text: 'Please enter a discount code',
|
||||
isValid: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCouponMessage(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await validateDiscountCode({}, couponCode);
|
||||
setCouponMessage({
|
||||
text: result.message,
|
||||
isValid: result.isValid
|
||||
});
|
||||
} catch (error) {
|
||||
setCouponMessage({
|
||||
text: 'Error validating coupon code',
|
||||
isValid: false
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Label className="mb-2">{t('cart.discountCode')}</Label>
|
||||
<form onSubmit={handleCouponSubmit} className="space-y-2">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 border rounded-l-md p-3"
|
||||
placeholder={t('cart.discountCodePlaceholder')}
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="rounded-l-none"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Loading...' : t('cart.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Coupon feedback message */}
|
||||
{couponMessage && (
|
||||
<div className={`flex items-center ${couponMessage.isValid ? 'text-green-600' : 'text-red-600'} mt-2`}>
|
||||
{couponMessage.isValid ?
|
||||
<CheckCircle size={16} className="mr-1" /> :
|
||||
<AlertCircle size={16} className="mr-1" />
|
||||
}
|
||||
<Text size="sm" as="span" className={couponMessage.isValid ? 'text-green-600' : 'text-red-600'}>
|
||||
{couponMessage.text}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
components/cart/CartLink.tsx
Normal file
23
components/cart/CartLink.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CartLink() {
|
||||
const { cart } = useCart();
|
||||
const itemCount = cart?.totalQuantity || 0;
|
||||
|
||||
return (
|
||||
<Link href="/cart" className="group -m-2 flex items-center p-2">
|
||||
<ShoppingCart
|
||||
className="h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-2 text-sm font-medium text-gray-700 group-hover:text-gray-800">
|
||||
{itemCount}
|
||||
</span>
|
||||
<span className="sr-only">items in cart, view cart</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
180
components/cart/CartPage.tsx
Normal file
180
components/cart/CartPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { container } from '@/lib/utils';
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import { useTransition } from 'react';
|
||||
import { removeItem, updateItemQuantity } from './actions';
|
||||
import { CartDiscountForm } from './CartDiscountForm';
|
||||
import { CartSummary } from './CartSummary';
|
||||
import { EmptyCartMessage } from './EmptyCartMessage';
|
||||
import { useCartProcessing } from './hooks/useCartProcessing';
|
||||
import { BoxesSection } from './sections/BoxesSection';
|
||||
import { CartHeader } from './sections/CartHeader';
|
||||
import { CartLoading } from './sections/CartLoading';
|
||||
import { OrderNotes } from './sections/OrderNotes';
|
||||
import { ProductsSection } from './sections/ProductsSection';
|
||||
|
||||
export default function CartPage() {
|
||||
const { cart, updateCartItem } = useCart();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { boxes, standaloneProducts, isGroupingComplete, didInitialProcess } = useCartProcessing(cart);
|
||||
|
||||
if (!cart?.lines.length) {
|
||||
return <EmptyCartMessage />;
|
||||
}
|
||||
|
||||
// Show loading state while processing cart items
|
||||
if (!didInitialProcess || !isGroupingComplete) {
|
||||
return <CartLoading />;
|
||||
}
|
||||
|
||||
const handleUpdateCartItem = (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => {
|
||||
// First update the client-side cart state for immediate feedback
|
||||
updateCartItem(merchandiseId, updateType, itemId);
|
||||
|
||||
// Update lastBoxState in localStorage if this is a box item
|
||||
try {
|
||||
const boxStateString = localStorage.getItem('lastBoxState');
|
||||
if (boxStateString) {
|
||||
const boxState = JSON.parse(boxStateString);
|
||||
|
||||
// Check if this item belongs to a box
|
||||
const cartItem = cart.lines.find(item => {
|
||||
if (itemId && item.id) {
|
||||
return item.id === itemId;
|
||||
}
|
||||
return item.merchandise.id === merchandiseId;
|
||||
});
|
||||
|
||||
if (cartItem) {
|
||||
// Check if it's a box item by looking at attributes
|
||||
const attrs = cartItem.attributes || [];
|
||||
const boxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
if (boxType && boxGroupId) {
|
||||
// Find the corresponding item in the stored state
|
||||
if (boxType === 'item' && boxState.productItems) {
|
||||
if (updateType === 'delete') {
|
||||
// Remove the item from productItems
|
||||
boxState.productItems = boxState.productItems.filter((item: { id: string, variantId: string }) =>
|
||||
item.id !== cartItem.merchandise.product.id ||
|
||||
item.variantId !== cartItem.merchandise.id
|
||||
);
|
||||
} else {
|
||||
// Update quantity
|
||||
const productItem = boxState.productItems.find((item: { id: string, variantId: string }) =>
|
||||
item.id === cartItem.merchandise.product.id &&
|
||||
item.variantId === cartItem.merchandise.id
|
||||
);
|
||||
|
||||
if (productItem) {
|
||||
const newQuantity = updateType === 'plus'
|
||||
? cartItem.quantity + 1
|
||||
: Math.max(1, cartItem.quantity - 1);
|
||||
productItem.quantity = newQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated state back to localStorage
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
}
|
||||
else if (boxType === 'container' && boxState.boxItems) {
|
||||
if (updateType === 'delete') {
|
||||
// Remove the box
|
||||
boxState.boxItems = boxState.boxItems.filter((item: { id: string }) =>
|
||||
item.id !== cartItem.merchandise.product.id
|
||||
);
|
||||
} else {
|
||||
// Update quantity
|
||||
const boxItem = boxState.boxItems.find((item: { id: string }) =>
|
||||
item.id === cartItem.merchandise.product.id
|
||||
);
|
||||
|
||||
if (boxItem) {
|
||||
const newQuantity = updateType === 'plus'
|
||||
? cartItem.quantity + 1
|
||||
: Math.max(1, cartItem.quantity - 1);
|
||||
boxItem.quantity = newQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated state back to localStorage
|
||||
localStorage.setItem('lastBoxState', JSON.stringify(boxState));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating box state in localStorage', error);
|
||||
}
|
||||
|
||||
// Then update the server-side cart
|
||||
startTransition(() => {
|
||||
if (updateType === 'delete') {
|
||||
// Call server action to remove item
|
||||
removeItem({}, merchandiseId, itemId);
|
||||
} else {
|
||||
// Find the specific item to update, using both merchandise ID and item ID if provided
|
||||
let item = cart.lines.find((item: { id?: string, merchandise: { id: string } }) => {
|
||||
if (itemId && item.id) {
|
||||
// If we have item ID, use it for more specific matching
|
||||
return item.id === itemId;
|
||||
}
|
||||
// Fall back to merchandise ID only
|
||||
return item.merchandise.id === merchandiseId;
|
||||
});
|
||||
|
||||
if (item) {
|
||||
// Calculate new quantity based on the updateType
|
||||
const newQuantity = updateType === 'plus'
|
||||
? item.quantity + 1
|
||||
: Math.max(1, item.quantity - 1);
|
||||
|
||||
// Call server action to update quantity
|
||||
updateItemQuantity({}, { merchandiseId, quantity: newQuantity, itemId });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
<div className="pb-20">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between gap-8">
|
||||
{/* Left Side: Cart Items */}
|
||||
<div className="lg:w-[62%]">
|
||||
<CartHeader totalQuantity={cart.totalQuantity} />
|
||||
|
||||
{/* Boxes Section */}
|
||||
<BoxesSection
|
||||
boxes={boxes}
|
||||
onUpdate={handleUpdateCartItem}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
{/* Standalone Products Section */}
|
||||
<ProductsSection
|
||||
products={standaloneProducts}
|
||||
onUpdate={handleUpdateCartItem}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
{/* Order Notes */}
|
||||
<OrderNotes />
|
||||
|
||||
{/* Discount Form */}
|
||||
<CartDiscountForm />
|
||||
</div>
|
||||
|
||||
{/* Right Side: Order Summary */}
|
||||
<div className="lg:w-[30%]">
|
||||
<div className="mt-6 lg:mt-[72px]">
|
||||
<CartSummary cart={cart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
components/cart/CartProductItem.tsx
Normal file
142
components/cart/CartProductItem.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { colorHexMap } from '@/components/products/utils/colorUtils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { useCart } from 'components/cart/cart-context';
|
||||
import Price from 'components/price';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { QuantityControls } from './QuantityControls';
|
||||
|
||||
interface CartProductItemProps {
|
||||
item: CartItem;
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
isInBox?: boolean;
|
||||
boxGroupId?: string;
|
||||
}
|
||||
|
||||
export function CartProductItem({ item, onUpdate, isPending, isInBox = false, boxGroupId }: CartProductItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { cart } = useCart();
|
||||
|
||||
// Check if this item has a color option
|
||||
const colorOption = item.merchandise.selectedOptions.find(option =>
|
||||
option.name.toLowerCase() === 'color' ||
|
||||
option.name.toLowerCase() === 'colour' ||
|
||||
option.name.toLowerCase() === 'boja'
|
||||
);
|
||||
|
||||
// Get the color hex code from the color name if it exists
|
||||
const colorHex = colorOption
|
||||
? colorHexMap[colorOption.value.toLowerCase()] || colorOption.value
|
||||
: null;
|
||||
|
||||
// Get the unique ID of this cart item
|
||||
const itemId = item.id;
|
||||
|
||||
// Handle quantity changes
|
||||
const handleIncrease = () => onUpdate(item.merchandise.id, 'plus', itemId);
|
||||
const handleDecrease = () => onUpdate(item.merchandise.id, 'minus', itemId);
|
||||
|
||||
// Enhanced delete handler
|
||||
const handleDelete = () => {
|
||||
// Basic delete operation for this item
|
||||
onUpdate(item.merchandise.id, 'delete', itemId);
|
||||
|
||||
// Special handling for box items
|
||||
if (isInBox && boxGroupId && cart) {
|
||||
// Find all items in this box group
|
||||
const boxItems = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
return itemBoxGroupId === boxGroupId && itemBoxType === 'item' && line.id !== itemId;
|
||||
});
|
||||
|
||||
// If this is the last item (only 1 left - the one we're deleting), also delete the box container
|
||||
if (boxItems.length === 0) {
|
||||
// Find the box container
|
||||
const boxContainer = cart.lines.find(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
|
||||
});
|
||||
|
||||
// Delete the box container too
|
||||
if (boxContainer && boxContainer.id) {
|
||||
onUpdate(boxContainer.merchandise.id, 'delete', boxContainer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap md:flex-nowrap items-start ${!isInBox ? 'border-b pb-6' : 'pb-4'}`}>
|
||||
{/* Product Image */}
|
||||
<div className="relative h-20 w-20 flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={item.merchandise.product.featuredImage?.url || ''}
|
||||
alt={item.merchandise.product.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 pl-4 min-w-0">
|
||||
<div className="flex flex-col">
|
||||
<Text weight={isInBox ? 'regular' : 'semibold'} className="pr-2 break-words">
|
||||
{item.merchandise.product.title}
|
||||
</Text>
|
||||
|
||||
{/* Show color indicator if color is available */}
|
||||
{colorHex && (
|
||||
<div className="flex items-center mt-1">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
aria-label={`${colorOption?.value || 'Unknown'}`}
|
||||
/>
|
||||
<Text size="xs" color="muted" as="span" className="ml-2">
|
||||
{colorOption?.value || ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Price
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
className="text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center ml-auto mt-2 md:mt-0">
|
||||
<div className="mr-4">
|
||||
<QuantityControls
|
||||
quantity={item.quantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
isDisabled={isPending}
|
||||
minQuantity={1}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="default"
|
||||
className="p-0 h-auto border-0 bg-transparent hover:bg-transparent text-gray-500 hover:text-red-500"
|
||||
disabled={isPending}
|
||||
aria-label={t('cart.remove')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/cart/CartSummary.tsx
Normal file
64
components/cart/CartSummary.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import Price from 'components/price';
|
||||
import { Cart } from 'lib/shopify/types';
|
||||
import { useState } from 'react';
|
||||
import { redirectToCheckout } from './actions';
|
||||
|
||||
interface CartSummaryProps {
|
||||
cart: Cart;
|
||||
}
|
||||
|
||||
export function CartSummary({ cart }: CartSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setIsLoading(true);
|
||||
await redirectToCheckout();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-6 bg-white sticky top-20">
|
||||
<Heading level={4} className="mb-6">{t('cart.orderSummary')}</Heading>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.subtotal')}</Text>
|
||||
<Price
|
||||
amount={cart.cost.subtotalAmount.amount}
|
||||
currencyCode={cart.cost.subtotalAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.shipping')}</Text>
|
||||
<Text size="sm" color="muted" className="text-right">{t('cart.calculated')}</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Text weight="semibold">{t('cart.total')}</Text>
|
||||
<Price
|
||||
amount={cart.cost.totalAmount.amount}
|
||||
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? "Loading..." : t('cart.continueToCheckout')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/cart/EmptyCartMessage.tsx
Normal file
25
components/cart/EmptyCartMessage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Section } from '@/components/ui/Section';
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
export function EmptyCartMessage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="flex flex-col items-center justify-center text-center p-8 max-w-md mx-auto border rounded-lg bg-gray-50">
|
||||
<Heading level={3} className="mb-3">{t('cart.emptyCart')}</Heading>
|
||||
<Text color="muted" className="mb-6">Add some items to your cart to see them here.</Text>
|
||||
<Button
|
||||
href="/products"
|
||||
variant="primary"
|
||||
>
|
||||
{t('cart.startShopping')}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
51
components/cart/QuantityControls.tsx
Normal file
51
components/cart/QuantityControls.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface QuantityControlsProps {
|
||||
quantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
isDisabled?: boolean;
|
||||
minQuantity?: number;
|
||||
}
|
||||
|
||||
export function QuantityControls({
|
||||
quantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
isDisabled = false,
|
||||
minQuantity = 1
|
||||
}: QuantityControlsProps) {
|
||||
return (
|
||||
<div className="flex border border-gray-300 rounded-md">
|
||||
<Button
|
||||
onClick={onDecrease}
|
||||
variant="default"
|
||||
className="px-3 py-1 border-0 rounded-none"
|
||||
disabled={isDisabled || quantity <= minQuantity}
|
||||
aria-label="Decrease quantity"
|
||||
size="sm"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</Button>
|
||||
<input
|
||||
type="text"
|
||||
value={quantity}
|
||||
readOnly
|
||||
className="w-10 text-center border-x border-gray-300"
|
||||
/>
|
||||
<Button
|
||||
onClick={onIncrease}
|
||||
variant="default"
|
||||
className="px-3 py-1 border-0 rounded-none"
|
||||
disabled={isDisabled}
|
||||
aria-label="Increase quantity"
|
||||
size="sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
components/cart/actions.ts
Normal file
251
components/cart/actions.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
'use server';
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function addItem(
|
||||
prevState: any,
|
||||
selectedVariantId: string | undefined,
|
||||
quantity: number = 1,
|
||||
attributes?: { key: string; value: string }[]
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cartId) {
|
||||
const cart = await createCart();
|
||||
cartId = cart.id!;
|
||||
cookieStore.set('cartId', cartId);
|
||||
}
|
||||
|
||||
await addToCart(cartId, [{
|
||||
merchandiseId: selectedVariantId,
|
||||
quantity,
|
||||
attributes
|
||||
}]);
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
return 'Error adding item to cart';
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeItem(prevState: any, merchandiseId: string, itemId?: string) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
// Find specific line item - first by itemId if provided, otherwise by merchandiseId
|
||||
let lineItem;
|
||||
|
||||
if (itemId) {
|
||||
// If we have a specific item ID (e.g., for box items), use that first
|
||||
lineItem = cart.lines.find((line) => line.id === itemId);
|
||||
}
|
||||
|
||||
// If no item ID was provided or no match was found, fall back to merchandise ID
|
||||
if (!lineItem) {
|
||||
lineItem = cart.lines.find((line) => line.merchandise.id === merchandiseId);
|
||||
}
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
// Check if this is a box item being removed
|
||||
const lineAttributes = lineItem.attributes || [];
|
||||
const boxType = lineAttributes.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = lineAttributes.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
// If this is a box item (not container), check if it's the last one before removing
|
||||
if (boxType === 'item' && boxGroupId) {
|
||||
// Find all items in this box (excluding the current one we're removing)
|
||||
const remainingBoxItems = cart.lines.filter(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const itemBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const itemBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
|
||||
return itemBoxGroupId === boxGroupId &&
|
||||
line.id !== lineItem.id &&
|
||||
itemBoxType === 'item';
|
||||
});
|
||||
|
||||
// If this is the last item, find the box container and remove both together
|
||||
if (remainingBoxItems.length === 0) {
|
||||
const boxContainer = cart.lines.find(line => {
|
||||
const attrs = line.attributes || [];
|
||||
const containerBoxGroupId = attrs.find(attr => attr.key === '_box_group_id')?.value;
|
||||
const containerBoxType = attrs.find(attr => attr.key === '_box_type')?.value;
|
||||
|
||||
return containerBoxGroupId === boxGroupId && containerBoxType === 'container';
|
||||
});
|
||||
|
||||
// If box container exists, remove both item and container in a single operation
|
||||
if (boxContainer && boxContainer.id) {
|
||||
// Batched removal of both the item and box container
|
||||
await removeFromCart(cartId, [lineItem.id, boxContainer.id]);
|
||||
} else {
|
||||
// Just remove the item if no container found
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
} else {
|
||||
// Not the last item, just remove this one
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
} else {
|
||||
// Regular item or box container, just remove it
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
}
|
||||
|
||||
revalidateTag(TAGS.cart);
|
||||
} else {
|
||||
return 'Item not found in cart';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Error removing item from cart';
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateItemQuantity(
|
||||
prevState: any,
|
||||
payload: {
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
itemId?: string;
|
||||
}
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
const { merchandiseId, quantity, itemId } = payload;
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
// Find the specific line item to update
|
||||
let lineItem;
|
||||
|
||||
if (itemId) {
|
||||
// First try to find by specific item ID (for items in multiple boxes)
|
||||
lineItem = cart.lines.find(line => line.id === itemId);
|
||||
}
|
||||
|
||||
// Fall back to finding by merchandise ID if item ID didn't work
|
||||
if (!lineItem) {
|
||||
lineItem = cart.lines.find(line => line.merchandise.id === merchandiseId);
|
||||
}
|
||||
|
||||
if (lineItem && lineItem.id) {
|
||||
if (quantity === 0) {
|
||||
await removeFromCart(cartId, [lineItem.id]);
|
||||
} else {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineItem.id,
|
||||
merchandiseId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
}
|
||||
} else if (quantity > 0) {
|
||||
// If the item doesn't exist in the cart and quantity > 0, add it
|
||||
await addToCart(cartId, [{ merchandiseId, quantity }]);
|
||||
}
|
||||
|
||||
revalidateTag(TAGS.cart);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return 'Error updating item quantity';
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToCheckout() {
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'No cart found';
|
||||
}
|
||||
|
||||
let cart = await getCart(cartId);
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
if (!cart.checkoutUrl) {
|
||||
return 'No checkout URL available';
|
||||
}
|
||||
|
||||
redirect(cart.checkoutUrl);
|
||||
}
|
||||
|
||||
export async function createCartAndSetCookie() {
|
||||
const cookieStore = await cookies();
|
||||
let cart = await createCart();
|
||||
cookieStore.set('cartId', cart.id!);
|
||||
}
|
||||
|
||||
export async function validateDiscountCode(prevState: any, discountCode: string) {
|
||||
if (!discountCode || discountCode.trim() === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'Please enter a discount code'
|
||||
};
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
let cartId = cookieStore.get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'No cart found. Please add items to your cart first.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement discount code validation
|
||||
const isValid = Math.random() > 0.5;
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
isValid: true,
|
||||
message: 'Discount code applied successfully!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'Invalid discount code. Please try again.'
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error validating discount code:', e);
|
||||
return {
|
||||
isValid: false,
|
||||
message: 'An error occurred while validating the discount code.'
|
||||
};
|
||||
}
|
||||
}
|
||||
115
components/cart/add-to-cart.tsx
Normal file
115
components/cart/add-to-cart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useProduct } from 'components/product/product-context';
|
||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useCart } from './cart-context';
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
selectedVariantId,
|
||||
isLoading
|
||||
}: {
|
||||
availableForSale: boolean;
|
||||
selectedVariantId: string | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
Out Of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Please select an option"
|
||||
disabled
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
Please Select Options
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Add to cart"
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add to Cart'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddToCart({ product, quantity = 1 }: { product: Product; quantity?: number }) {
|
||||
const { variants, availableForSale } = product;
|
||||
const { addCartItem } = useCart();
|
||||
const { state } = useProduct();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
||||
);
|
||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Don't proceed if the form is already being processed
|
||||
if (isLoading) return;
|
||||
|
||||
// Don't proceed if there's no variant ID
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
// Set loading state
|
||||
setIsLoading(true);
|
||||
|
||||
// Use the quantity prop passed to the component
|
||||
// No need to get it from formData since there's no quantity input in the form
|
||||
|
||||
const finalVariant = variants.find((v) => v.id === selectedVariantId);
|
||||
if (!finalVariant) return;
|
||||
|
||||
// Call the server action to add the item to the cart
|
||||
// @ts-ignore - We know our server action accepts quantity
|
||||
addItem(null, selectedVariantId, quantity)
|
||||
.then(() => {
|
||||
// Add to context cart for immediate UI feedback
|
||||
addCartItem(finalVariant, product, quantity);
|
||||
});
|
||||
|
||||
// Add a small delay before removing loading state for better UX
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
selectedVariantId={selectedVariantId}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
217
components/cart/cart-context.tsx
Normal file
217
components/cart/cart-context.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
|
||||
import React, { createContext, use, useContext, useEffect, useMemo, useOptimistic, useTransition } from 'react';
|
||||
|
||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||
|
||||
type CartAction =
|
||||
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType; itemId?: string } }
|
||||
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product, quantity: number } };
|
||||
|
||||
type CartContextType = {
|
||||
cart: Cart | undefined;
|
||||
updateCartItem: (merchandiseId: string, updateType: UpdateType, itemId?: string) => void;
|
||||
addCartItem: (variant: ProductVariant, product: Product, quantity?: number) => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
function calculateItemCost(quantity: number, price: string): string {
|
||||
return (Number(price) * quantity).toString();
|
||||
}
|
||||
|
||||
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
|
||||
if (updateType === 'delete') return null;
|
||||
|
||||
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
|
||||
if (newQuantity === 0) return null;
|
||||
|
||||
const singleItemAmount = Number(item.cost.totalAmount.amount) / item.quantity;
|
||||
const newTotalAmount = calculateItemCost(newQuantity, singleItemAmount.toString());
|
||||
|
||||
return {
|
||||
...item,
|
||||
quantity: newQuantity,
|
||||
cost: {
|
||||
...item.cost,
|
||||
totalAmount: {
|
||||
...item.cost.totalAmount,
|
||||
amount: newTotalAmount
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createOrUpdateCartItem(
|
||||
existingItem: CartItem | undefined,
|
||||
variant: ProductVariant,
|
||||
product: Product,
|
||||
quantity: number = 1
|
||||
): CartItem {
|
||||
const newQuantity = existingItem ? existingItem.quantity + quantity : quantity;
|
||||
const totalAmount = calculateItemCost(newQuantity, variant.price.amount);
|
||||
|
||||
return {
|
||||
id: existingItem?.id,
|
||||
quantity: newQuantity,
|
||||
attributes: existingItem?.attributes || [],
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: totalAmount,
|
||||
currencyCode: variant.price.currencyCode
|
||||
}
|
||||
},
|
||||
merchandise: {
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
selectedOptions: variant.selectedOptions,
|
||||
product: {
|
||||
id: product.id,
|
||||
handle: product.handle,
|
||||
title: product.title,
|
||||
featuredImage: product.featuredImage
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
|
||||
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
|
||||
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
|
||||
|
||||
return {
|
||||
totalQuantity,
|
||||
cost: {
|
||||
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalTaxAmount: { amount: '0', currencyCode }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCart(): Cart {
|
||||
return {
|
||||
id: undefined,
|
||||
checkoutUrl: '',
|
||||
totalQuantity: 0,
|
||||
lines: [],
|
||||
cost: {
|
||||
subtotalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
const currentCart = state || createEmptyCart();
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_ITEM': {
|
||||
const { merchandiseId, updateType, itemId } = action.payload;
|
||||
const updatedLines = currentCart.lines
|
||||
.map((item) => {
|
||||
// If itemId is provided, only update the specific item
|
||||
if (itemId && item.id !== itemId) {
|
||||
return item;
|
||||
}
|
||||
// Otherwise, update by merchandise ID
|
||||
if (!itemId && item.merchandise.id !== merchandiseId) {
|
||||
return item;
|
||||
}
|
||||
// Update the matching item
|
||||
return updateCartItem(item, updateType);
|
||||
})
|
||||
.filter(Boolean) as CartItem[];
|
||||
|
||||
if (updatedLines.length === 0) {
|
||||
return {
|
||||
...currentCart,
|
||||
lines: [],
|
||||
totalQuantity: 0,
|
||||
cost: {
|
||||
...currentCart.cost,
|
||||
totalAmount: { ...currentCart.cost.totalAmount, amount: '0' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
|
||||
}
|
||||
case 'ADD_ITEM': {
|
||||
const { variant, product, quantity = 1 } = action.payload;
|
||||
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
|
||||
const updatedItem = createOrUpdateCartItem(existingItem, variant, product, quantity);
|
||||
|
||||
const updatedLines = existingItem
|
||||
? currentCart.lines.map((item) => (item.merchandise.id === variant.id ? updatedItem : item))
|
||||
: [...currentCart.lines, updatedItem];
|
||||
|
||||
return { ...currentCart, ...updateCartTotals(updatedLines), lines: updatedLines };
|
||||
}
|
||||
default:
|
||||
return currentCart;
|
||||
}
|
||||
}
|
||||
|
||||
export function CartProvider({
|
||||
children,
|
||||
cartPromise
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
}) {
|
||||
const initialCart = use(cartPromise);
|
||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// More detailed debugging for cart loading
|
||||
useEffect(() => {
|
||||
if (initialCart?.lines?.length) {
|
||||
// Detailed inspection of the cart items and their attributes
|
||||
const itemsWithAttributes = initialCart.lines.filter(item =>
|
||||
item.attributes && item.attributes.length > 0
|
||||
);
|
||||
|
||||
// Log each item with its attributes for debugging
|
||||
initialCart.lines.forEach((item, index) => {
|
||||
const attrs = item.attributes || [];
|
||||
const boxType = attrs.find(a => a.key === 'box_type')?.value;
|
||||
const boxGroupId = attrs.find(a => a.key === 'box_group_id')?.value;
|
||||
});
|
||||
}
|
||||
}, [initialCart]);
|
||||
|
||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType, itemId?: string) => {
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType, itemId } });
|
||||
});
|
||||
};
|
||||
|
||||
const addCartItem = (variant: ProductVariant, product: Product, quantity: number = 1) => {
|
||||
startTransition(() => {
|
||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product, quantity } });
|
||||
});
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
cart: optimisticCart,
|
||||
updateCartItem,
|
||||
addCartItem
|
||||
}),
|
||||
[optimisticCart]
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCart must be used within a CartProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
100
components/cart/hooks/useCartProcessing.ts
Normal file
100
components/cart/hooks/useCartProcessing.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Cart } from 'lib/shopify/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useCartProcessing(cart: Cart | undefined) {
|
||||
const [boxGroups, setBoxGroups] = useState(new Map<string, { box: any; products: any[] }>());
|
||||
const [standaloneProducts, setStandaloneProducts] = useState<any[]>([]);
|
||||
const [isGroupingComplete, setIsGroupingComplete] = useState(false);
|
||||
const [didInitialProcess, setDidInitialProcess] = useState(false);
|
||||
|
||||
// First-time cart load processing with delay
|
||||
useEffect(() => {
|
||||
if (!cart?.lines?.length) {
|
||||
setIsGroupingComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a small timeout to ensure cart data is fully loaded
|
||||
const timeoutId = setTimeout(() => {
|
||||
processCartItems();
|
||||
}, 100); // 100ms delay
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// Process cart items whenever the cart changes after initial load
|
||||
useEffect(() => {
|
||||
if (!didInitialProcess && cart?.lines?.length) {
|
||||
// Skip first update - handled by the first effect
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cart?.lines?.length) {
|
||||
setIsGroupingComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
processCartItems();
|
||||
}, [cart, didInitialProcess]);
|
||||
|
||||
// Process cart items function
|
||||
const processCartItems = () => {
|
||||
if (!cart) return;
|
||||
|
||||
// Set grouping as incomplete at the start
|
||||
setIsGroupingComplete(false);
|
||||
|
||||
const newBoxGroups = new Map<string, { box: any; products: any[] }>();
|
||||
const newStandaloneProducts: any[] = [];
|
||||
|
||||
// First, sort all items into boxes or standalone products
|
||||
cart.lines.forEach(item => {
|
||||
// Skip items without attributes (avoid runtime errors)
|
||||
if (!item.attributes || item.attributes.length === 0) {
|
||||
newStandaloneProducts.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const boxType = item.attributes.find(attr => attr.key === '_box_type')?.value;
|
||||
const boxGroupId = item.attributes.find(attr => attr.key === '_box_group_id')?.value;
|
||||
|
||||
if (boxType === 'container' && boxGroupId) {
|
||||
// This is a box container
|
||||
if (!newBoxGroups.has(boxGroupId)) {
|
||||
newBoxGroups.set(boxGroupId, { box: item, products: [] });
|
||||
} else {
|
||||
newBoxGroups.get(boxGroupId)!.box = item;
|
||||
}
|
||||
} else if (boxType === 'item' && boxGroupId) {
|
||||
// This is an item that belongs in a box
|
||||
if (!newBoxGroups.has(boxGroupId)) {
|
||||
newBoxGroups.set(boxGroupId, { box: null, products: [item] });
|
||||
} else {
|
||||
newBoxGroups.get(boxGroupId)!.products.push(item);
|
||||
}
|
||||
} else {
|
||||
// This is a standalone product
|
||||
newStandaloneProducts.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
setBoxGroups(newBoxGroups);
|
||||
setStandaloneProducts(newStandaloneProducts);
|
||||
setDidInitialProcess(true);
|
||||
|
||||
// Mark grouping as complete
|
||||
setIsGroupingComplete(true);
|
||||
};
|
||||
|
||||
// Get boxes array from the map for rendering
|
||||
const boxes = Array.from(boxGroups.values()).filter(group => group.box);
|
||||
|
||||
return {
|
||||
boxes,
|
||||
standaloneProducts,
|
||||
isGroupingComplete,
|
||||
didInitialProcess
|
||||
};
|
||||
}
|
||||
22
components/cart/processCartItems.tsx
Normal file
22
components/cart/processCartItems.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// Function to get a unique identifier for a cart item
|
||||
export function getUniqueItemKey(item: any, boxGroupId?: string, index?: number): string {
|
||||
// Start with the merchandise id
|
||||
let key = item.merchandise.id;
|
||||
|
||||
// Add the item's own id if available
|
||||
if (item.id) {
|
||||
key = `${key}-${item.id}`;
|
||||
}
|
||||
|
||||
// Add box group id if it's part of a box
|
||||
if (boxGroupId) {
|
||||
key = `${boxGroupId}-${key}`;
|
||||
}
|
||||
|
||||
// Add index as fallback to ensure uniqueness
|
||||
if (index !== undefined) {
|
||||
key = `${key}-${index}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
34
components/cart/sections/BoxesSection.tsx
Normal file
34
components/cart/sections/BoxesSection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { CartBoxItem } from '../CartBoxItem';
|
||||
|
||||
interface BoxesSectionProps {
|
||||
boxes: Array<{ box: CartItem; products: CartItem[] }>;
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function BoxesSection({ boxes, onUpdate, isPending }: BoxesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (boxes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-4">{t('cart.boxes')}</h2>
|
||||
<div className="space-y-4">
|
||||
{boxes.map((boxGroup) => (
|
||||
<CartBoxItem
|
||||
key={boxGroup.box.id}
|
||||
boxItem={boxGroup.box}
|
||||
boxProducts={boxGroup.products}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/cart/sections/CartHeader.tsx
Normal file
20
components/cart/sections/CartHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
interface CartHeaderProps {
|
||||
totalQuantity: number;
|
||||
}
|
||||
|
||||
export function CartHeader({ totalQuantity }: CartHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="my-6 flex items-center justify-between">
|
||||
<Heading level={2}>{t('cart.title')}</Heading>
|
||||
<div className="bg-gray-100 rounded-md px-4 py-2">
|
||||
<Text>{t('cart.itemCount').replace('{count}', totalQuantity.toString())}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/cart/sections/CartLoading.tsx
Normal file
18
components/cart/sections/CartLoading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { container } from '@/lib/utils';
|
||||
|
||||
export function CartLoading() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-pulse text-center">
|
||||
<p className="text-lg">{t('cart.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/cart/sections/OrderNotes.tsx
Normal file
18
components/cart/sections/OrderNotes.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
|
||||
export function OrderNotes() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<Label className="mb-2">{t('cart.notes')}</Label>
|
||||
<textarea
|
||||
className="w-full border rounded-md p-3 min-h-[100px]"
|
||||
placeholder={t('cart.notesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
components/cart/sections/ProductsSection.tsx
Normal file
34
components/cart/sections/ProductsSection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from '@/lib/hooks/useTranslation';
|
||||
import { CartItem } from 'lib/shopify/types';
|
||||
import { CartProductItem } from '../CartProductItem';
|
||||
import { getUniqueItemKey } from '../processCartItems';
|
||||
|
||||
interface ProductsSectionProps {
|
||||
products: CartItem[];
|
||||
onUpdate: (merchandiseId: string, updateType: 'plus' | 'minus' | 'delete', itemId?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function ProductsSection({ products, onUpdate, isPending }: ProductsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-4">{t('cart.products')}</h2>
|
||||
<div className="space-y-6">
|
||||
{products.map((item, index) => (
|
||||
<CartProductItem
|
||||
key={getUniqueItemKey(item, 'standalone', index)}
|
||||
item={item}
|
||||
onUpdate={onUpdate}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user