Changes
Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
@@ -1,149 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { ShoppingCart, Trash2, ExternalLink, Loader2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
|
||||||
|
|
||||||
export const CartDrawer = () => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { items, isLoading, isSyncing, removeItem, clearCart, getCheckoutUrl, syncCart } = useCartStore();
|
|
||||||
|
|
||||||
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
|
|
||||||
const totalPrice = items.reduce((sum, item) => sum + (parseFloat(item.price.amount) * item.quantity), 0);
|
|
||||||
|
|
||||||
// Sync cart with Shopify when drawer opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) syncCart();
|
|
||||||
}, [isOpen, syncCart]);
|
|
||||||
|
|
||||||
const handleCheckout = () => {
|
|
||||||
const checkoutUrl = getCheckoutUrl();
|
|
||||||
if (checkoutUrl) {
|
|
||||||
window.open(checkoutUrl, '_blank');
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = async (variantId: string) => {
|
|
||||||
await removeItem(variantId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (totalItems === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="relative">
|
|
||||||
<ShoppingCart className="h-5 w-5" />
|
|
||||||
{totalItems > 0 && (
|
|
||||||
<Badge className="absolute -top-2 -right-2 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-primary text-primary-foreground">
|
|
||||||
{totalItems}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent className="w-full sm:max-w-md flex flex-col h-full">
|
|
||||||
<SheetHeader className="flex-shrink-0">
|
|
||||||
<SheetTitle>Varukorg</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
{totalItems === 0 ? "Din varukorg är tom" : `${totalItems} produkt${totalItems !== 1 ? 'er' : ''} i varukorgen`}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 pt-6 min-h-0">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground">Din varukorg är tom</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.variantId} className="flex gap-4 p-3 bg-muted/30 rounded-lg">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="font-medium text-sm truncate">
|
|
||||||
{item.product.node.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Antal: {item.quantity}
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-primary">
|
|
||||||
{parseFloat(item.price.amount).toLocaleString('sv-SE')} {item.price.currencyCode}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end justify-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => handleRemoveItem(item.variantId)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0 space-y-4 pt-4 border-t bg-background">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-lg font-semibold">Totalt</span>
|
|
||||||
<span className="text-xl font-bold text-primary">
|
|
||||||
{totalPrice.toLocaleString('sv-SE')} {items[0]?.price.currencyCode || 'SEK'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleCheckout}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
disabled={items.length === 0 || isLoading || isSyncing}
|
|
||||||
>
|
|
||||||
{isLoading || isSyncing ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
Gå till betalning
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
clearCart();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-muted-foreground"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Töm varukorg
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CartDrawer;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useCartStore } from '@/stores/cartStore';
|
|
||||||
|
|
||||||
export function useCartSync() {
|
|
||||||
const syncCart = useCartStore(state => state.syncCart);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
syncCart();
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible') syncCart();
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
}, [syncCart]);
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
||||||
import {
|
|
||||||
storefrontApiRequest,
|
|
||||||
ShopifyProduct,
|
|
||||||
CART_QUERY,
|
|
||||||
CART_CREATE_MUTATION,
|
|
||||||
CART_LINES_ADD_MUTATION,
|
|
||||||
CART_LINES_UPDATE_MUTATION,
|
|
||||||
CART_LINES_REMOVE_MUTATION,
|
|
||||||
formatCheckoutUrl,
|
|
||||||
isCartNotFoundError
|
|
||||||
} from '@/lib/shopify';
|
|
||||||
|
|
||||||
export interface CartItem {
|
|
||||||
lineId: string | null;
|
|
||||||
product: ShopifyProduct;
|
|
||||||
variantId: string;
|
|
||||||
variantTitle: string;
|
|
||||||
price: { amount: string; currencyCode: string };
|
|
||||||
quantity: number;
|
|
||||||
selectedOptions: Array<{ name: string; value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CartStore {
|
|
||||||
items: CartItem[];
|
|
||||||
cartId: string | null;
|
|
||||||
checkoutUrl: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isSyncing: boolean;
|
|
||||||
addItem: (item: Omit<CartItem, 'lineId'>) => Promise<void>;
|
|
||||||
updateQuantity: (variantId: string, quantity: number) => Promise<void>;
|
|
||||||
removeItem: (variantId: string) => Promise<void>;
|
|
||||||
clearCart: () => void;
|
|
||||||
syncCart: () => Promise<void>;
|
|
||||||
getCheckoutUrl: () => string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createShopifyCart(item: CartItem): Promise<{ cartId: string; checkoutUrl: string; lineId: string } | null> {
|
|
||||||
const data = await storefrontApiRequest(CART_CREATE_MUTATION, {
|
|
||||||
input: { lines: [{ quantity: item.quantity, merchandiseId: item.variantId }] },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.data?.cartCreate?.userErrors?.length > 0) {
|
|
||||||
console.error('Cart creation failed:', data.data.cartCreate.userErrors);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cart = data?.data?.cartCreate?.cart;
|
|
||||||
if (!cart?.checkoutUrl) return null;
|
|
||||||
|
|
||||||
const lineId = cart.lines.edges[0]?.node?.id;
|
|
||||||
if (!lineId) return null;
|
|
||||||
|
|
||||||
return { cartId: cart.id, checkoutUrl: formatCheckoutUrl(cart.checkoutUrl), lineId };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addLineToShopifyCart(cartId: string, item: CartItem): Promise<{ success: boolean; lineId?: string; cartNotFound?: boolean }> {
|
|
||||||
const data = await storefrontApiRequest(CART_LINES_ADD_MUTATION, {
|
|
||||||
cartId,
|
|
||||||
lines: [{ quantity: item.quantity, merchandiseId: item.variantId }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const userErrors = data?.data?.cartLinesAdd?.userErrors || [];
|
|
||||||
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
|
|
||||||
if (userErrors.length > 0) {
|
|
||||||
console.error('Add line failed:', userErrors);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = data?.data?.cartLinesAdd?.cart?.lines?.edges || [];
|
|
||||||
const newLine = lines.find((l: { node: { id: string; merchandise: { id: string } } }) => l.node.merchandise.id === item.variantId);
|
|
||||||
return { success: true, lineId: newLine?.node?.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateShopifyCartLine(cartId: string, lineId: string, quantity: number): Promise<{ success: boolean; cartNotFound?: boolean }> {
|
|
||||||
const data = await storefrontApiRequest(CART_LINES_UPDATE_MUTATION, {
|
|
||||||
cartId,
|
|
||||||
lines: [{ id: lineId, quantity }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const userErrors = data?.data?.cartLinesUpdate?.userErrors || [];
|
|
||||||
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
|
|
||||||
if (userErrors.length > 0) {
|
|
||||||
console.error('Update line failed:', userErrors);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeLineFromShopifyCart(cartId: string, lineId: string): Promise<{ success: boolean; cartNotFound?: boolean }> {
|
|
||||||
const data = await storefrontApiRequest(CART_LINES_REMOVE_MUTATION, {
|
|
||||||
cartId,
|
|
||||||
lineIds: [lineId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const userErrors = data?.data?.cartLinesRemove?.userErrors || [];
|
|
||||||
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
|
|
||||||
if (userErrors.length > 0) {
|
|
||||||
console.error('Remove line failed:', userErrors);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCartStore = create<CartStore>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
items: [],
|
|
||||||
cartId: null,
|
|
||||||
checkoutUrl: null,
|
|
||||||
isLoading: false,
|
|
||||||
isSyncing: false,
|
|
||||||
|
|
||||||
addItem: async (item) => {
|
|
||||||
const { items, cartId, clearCart } = get();
|
|
||||||
const existingItem = items.find(i => i.variantId === item.variantId);
|
|
||||||
|
|
||||||
set({ isLoading: true });
|
|
||||||
try {
|
|
||||||
if (!cartId) {
|
|
||||||
const result = await createShopifyCart({ ...item, lineId: null });
|
|
||||||
if (result) {
|
|
||||||
set({
|
|
||||||
cartId: result.cartId,
|
|
||||||
checkoutUrl: result.checkoutUrl,
|
|
||||||
items: [{ ...item, lineId: result.lineId }]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (existingItem) {
|
|
||||||
const newQuantity = existingItem.quantity + item.quantity;
|
|
||||||
if (!existingItem.lineId) {
|
|
||||||
console.error('Cannot update quantity for item without lineId:', existingItem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await updateShopifyCartLine(cartId, existingItem.lineId, newQuantity);
|
|
||||||
if (result.success) {
|
|
||||||
const currentItems = get().items;
|
|
||||||
set({ items: currentItems.map(i => i.variantId === item.variantId ? { ...i, quantity: newQuantity } : i) });
|
|
||||||
} else if (result.cartNotFound) {
|
|
||||||
clearCart();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await addLineToShopifyCart(cartId, { ...item, lineId: null });
|
|
||||||
if (result.success) {
|
|
||||||
const currentItems = get().items;
|
|
||||||
set({ items: [...currentItems, { ...item, lineId: result.lineId ?? null }] });
|
|
||||||
} else if (result.cartNotFound) {
|
|
||||||
clearCart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add item:', error);
|
|
||||||
} finally {
|
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateQuantity: async (variantId, quantity) => {
|
|
||||||
if (quantity <= 0) {
|
|
||||||
await get().removeItem(variantId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, cartId, clearCart } = get();
|
|
||||||
const item = items.find(i => i.variantId === variantId);
|
|
||||||
if (!item?.lineId || !cartId) return;
|
|
||||||
|
|
||||||
set({ isLoading: true });
|
|
||||||
try {
|
|
||||||
const result = await updateShopifyCartLine(cartId, item.lineId, quantity);
|
|
||||||
if (result.success) {
|
|
||||||
const currentItems = get().items;
|
|
||||||
set({ items: currentItems.map(i => i.variantId === variantId ? { ...i, quantity } : i) });
|
|
||||||
} else if (result.cartNotFound) {
|
|
||||||
clearCart();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update quantity:', error);
|
|
||||||
} finally {
|
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeItem: async (variantId) => {
|
|
||||||
const { items, cartId, clearCart } = get();
|
|
||||||
const item = items.find(i => i.variantId === variantId);
|
|
||||||
if (!item?.lineId || !cartId) return;
|
|
||||||
|
|
||||||
set({ isLoading: true });
|
|
||||||
try {
|
|
||||||
const result = await removeLineFromShopifyCart(cartId, item.lineId);
|
|
||||||
if (result.success) {
|
|
||||||
const currentItems = get().items;
|
|
||||||
const newItems = currentItems.filter(i => i.variantId !== variantId);
|
|
||||||
newItems.length === 0 ? clearCart() : set({ items: newItems });
|
|
||||||
} else if (result.cartNotFound) {
|
|
||||||
clearCart();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove item:', error);
|
|
||||||
} finally {
|
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearCart: () => set({ items: [], cartId: null, checkoutUrl: null }),
|
|
||||||
getCheckoutUrl: () => get().checkoutUrl,
|
|
||||||
|
|
||||||
syncCart: async () => {
|
|
||||||
const { cartId, isSyncing, clearCart, items } = get();
|
|
||||||
if (!cartId || isSyncing) return;
|
|
||||||
|
|
||||||
set({ isSyncing: true });
|
|
||||||
try {
|
|
||||||
const data = await storefrontApiRequest(CART_QUERY, { id: cartId });
|
|
||||||
if (!data) {
|
|
||||||
set({ isSyncing: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cart = data?.data?.cart;
|
|
||||||
|
|
||||||
// Cart doesn't exist or is empty - clear local state
|
|
||||||
if (!cart || cart.totalQuantity === 0) {
|
|
||||||
clearCart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync local items with Shopify cart
|
|
||||||
const shopifyLines = cart.lines?.edges || [];
|
|
||||||
|
|
||||||
// Build a map of Shopify line items by variant ID
|
|
||||||
const shopifyItemsMap = new Map<string, { lineId: string; quantity: number }>();
|
|
||||||
for (const edge of shopifyLines) {
|
|
||||||
const variantId = edge.node.merchandise.id;
|
|
||||||
shopifyItemsMap.set(variantId, {
|
|
||||||
lineId: edge.node.id,
|
|
||||||
quantity: edge.node.quantity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local items to match Shopify state
|
|
||||||
const syncedItems = items
|
|
||||||
.filter(item => shopifyItemsMap.has(item.variantId))
|
|
||||||
.map(item => {
|
|
||||||
const shopifyItem = shopifyItemsMap.get(item.variantId)!;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
lineId: shopifyItem.lineId,
|
|
||||||
quantity: shopifyItem.quantity
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update checkout URL if available
|
|
||||||
const checkoutUrl = cart.checkoutUrl ? formatCheckoutUrl(cart.checkoutUrl) : get().checkoutUrl;
|
|
||||||
|
|
||||||
if (syncedItems.length === 0) {
|
|
||||||
clearCart();
|
|
||||||
} else {
|
|
||||||
set({ items: syncedItems, checkoutUrl });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to sync cart with Shopify:', error);
|
|
||||||
} finally {
|
|
||||||
set({ isSyncing: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'shopify-cart',
|
|
||||||
storage: createJSONStorage(() => localStorage),
|
|
||||||
partialize: (state) => ({ items: state.items, cartId: state.cartId, checkoutUrl: state.checkoutUrl }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user