diff --git a/src/components/cart/CartDrawer.tsx b/src/components/cart/CartDrawer.tsx index cf0f6ae..9c9fbf5 100644 --- a/src/components/cart/CartDrawer.tsx +++ b/src/components/cart/CartDrawer.tsx @@ -1,20 +1,42 @@ "use client"; +import { useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; -import { useCartStore } from "@/stores/cartStore"; -import { formatPrice } from "@/lib/woocommerce"; +import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; +import { formatPrice } from "@/lib/saleor"; export default function CartDrawer() { - const { items, isOpen, closeCart, removeItem, updateQuantity, getTotal } = useCartStore(); + const { + checkout, + isOpen, + isLoading, + error, + closeCart, + removeLine, + updateLine, + getTotal, + getLineCount, + getLines, + initCheckout, + clearError, + } = useSaleorCheckoutStore(); + const lines = getLines(); const total = getTotal(); + const lineCount = getLineCount(); + + // Initialize checkout on mount + useEffect(() => { + initCheckout(); + }, [initCheckout]); return ( {isOpen && ( <> + {/* Backdrop */} + + {/* Drawer */} + {/* Header */}
-

Your Cart

+

+ Your Cart ({lineCount}) +

+ {/* Error Message */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Cart Items */}
- {items.length === 0 ? ( + {lines.length === 0 ? (

Your cart is empty

@@ -56,39 +97,53 @@ export default function CartDrawer() {
) : (
- {items.map((item) => ( -
+ {lines.map((line) => ( +
+ {/* Product Image */}
- {item.image && ( + {line.variant.product.media[0]?.url && ( {item.name} )}
+ + {/* Product Info */}
-

{item.name}

+

{line.variant.product.name}

+ {line.variant.name !== "Default" && ( +

{line.variant.name}

+ )}

- {formatPrice(item.price)} + {formatPrice( + line.variant.pricing?.price?.gross?.amount || 0, + line.variant.pricing?.price?.gross?.currency + )}

+ + {/* Quantity Controls */}
- {item.quantity} + {line.quantity}
- {items.length > 0 && ( + {/* Footer with Checkout */} + {lines.length > 0 && (
-
- Subtotal - {formatPrice(total.toString())} + {/* Subtotal */} +
+ Subtotal + {formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}
- + Shipping + + {checkout?.shippingPrice?.gross?.amount + ? formatPrice(checkout.shippingPrice.gross.amount) + : "Calculated at checkout" + } + +
+ + {/* Total */} +
+ Total + {formatPrice(total)} +
+ + {/* Checkout Button */} + - Checkout -
+ {isLoading ? "Processing..." : "Checkout"} + + + {/* Continue Shopping */} +
)} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 7c4ea2b..3209e1d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,19 +1,24 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; import { AnimatePresence } from "framer-motion"; -import { useCartStore } from "@/stores/cartStore"; +import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { User, ShoppingBag, Menu } from "lucide-react"; import MobileMenu from "./MobileMenu"; import CartDrawer from "@/components/cart/CartDrawer"; export default function Header() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const { items, toggleCart } = useCartStore(); + const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore(); - const itemCount = items.reduce((count, item) => count + item.quantity, 0); + const itemCount = getLineCount(); + + // Initialize checkout on mount + useEffect(() => { + initCheckout(); + }, [initCheckout]); return ( <> diff --git a/src/components/product/ProductDetail.tsx b/src/components/product/ProductDetail.tsx index 015fa40..c712960 100644 --- a/src/components/product/ProductDetail.tsx +++ b/src/components/product/ProductDetail.tsx @@ -3,45 +3,55 @@ import { useState } from "react"; import Image from "next/image"; import { motion } from "framer-motion"; -import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce"; -import { useCartStore } from "@/stores/cartStore"; +import type { Product } from "@/types/saleor"; +import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; +import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor"; import ProductCard from "@/components/product/ProductCard"; interface ProductDetailProps { - product: WooProduct; - relatedProducts: WooProduct[]; + product: Product; + relatedProducts: Product[]; + locale?: string; } -export default function ProductDetail({ product, relatedProducts }: ProductDetailProps) { +export default function ProductDetail({ product, relatedProducts, locale = "SR" }: ProductDetailProps) { const [selectedImage, setSelectedImage] = useState(0); const [quantity, setQuantity] = useState(1); - const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details"); - const addItem = useCartStore((state) => state.addItem); + const [isAdding, setIsAdding] = useState(false); + const { addLine, openCart } = useSaleorCheckoutStore(); - const images = product.images?.length > 0 - ? product.images - : [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }]; + const localized = getLocalizedProduct(product, locale); + const variant = product.variants?.[0]; + const images = product.media?.length > 0 + ? product.media + : [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" }]; - const handleAddToCart = () => { - addItem({ - id: product.id, - name: product.name, - price: product.price || product.regular_price, - quantity, - image: images[0]?.src || "", - sku: product.sku || "", - }); + const handleAddToCart = async () => { + if (!variant?.id) return; + + setIsAdding(true); + try { + await addLine(variant.id, quantity); + openCart(); + } finally { + setIsAdding(false); + } }; const stripHtml = (html: string) => { + if (!html) return ""; return html.replace(/<[^>]*>/g, ""); }; + const isAvailable = variant?.quantityAvailable > 0; + const price = getProductPrice(product); + return ( <>
+ {/* Product Images */} {images[selectedImage] && ( {images[selectedImage].alt {image.alt @@ -81,97 +91,102 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai )} + {/* Product Info */}

- {product.name} + {localized.name}

- {product.price ? formatPrice(product.price) : "Contact for price"} + {price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}

+ {/* Short Description */}
-

{stripHtml(product.short_description || product.description.slice(0, 200))}

+

{stripHtml(localized.description).slice(0, 200)}...

- {product.stock_status === "instock" ? ( + {/* Add to Cart */} + {isAvailable ? (
+ {/* Quantity Selector */}
- {quantity} + {quantity}
+ {/* Add to Cart Button */}
) : (
- Out of Stock + {locale === "EN" ? "Out of Stock" : "Nema na stanju"}
)} -
-
- {(["details", "ingredients", "usage"] as const).map((tab) => ( - - ))} + {/* SKU */} + {variant?.sku && ( +

+ SKU: {variant.sku} +

+ )} + + {/* Full Description */} + {localized.description && ( +
+

+ {locale === "EN" ? "Description" : "Opis"} +

+
- -
- {activeTab === "details" && ( -

{stripHtml(product.description)}

- )} - {activeTab === "ingredients" && ( -

Natural ingredients - Contact for detailed information.

- )} - {activeTab === "usage" && ( -

Apply to clean skin or hair. Use daily for best results.

- )} -
-
+ )}
+ {/* Related Products */} {relatedProducts.length > 0 && (

- You May Also Like + {locale === "EN" ? "You May Also Like" : "Možda će vam se svideti"}

- {relatedProducts.map((product, index) => ( - + {relatedProducts.map((relatedProduct, index) => ( + ))}
diff --git a/src/stores/saleorCheckoutStore.ts b/src/stores/saleorCheckoutStore.ts new file mode 100644 index 0000000..70e877a --- /dev/null +++ b/src/stores/saleorCheckoutStore.ts @@ -0,0 +1,281 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { saleorClient } from "@/lib/saleor/client"; +import { + CHECKOUT_CREATE, + CHECKOUT_LINES_ADD, + CHECKOUT_LINES_UPDATE, + CHECKOUT_LINES_DELETE, + CHECKOUT_EMAIL_UPDATE, +} from "@/lib/saleor/mutations/Checkout"; +import { GET_CHECKOUT } from "@/lib/saleor/queries/Checkout"; +import type { Checkout, CheckoutLine } from "@/types/saleor"; + +const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel"; + +interface SaleorCheckoutStore { + checkout: Checkout | null; + checkoutToken: string | null; + isOpen: boolean; + isLoading: boolean; + error: string | null; + + // Actions + initCheckout: () => Promise; + addLine: (variantId: string, quantity: number) => Promise; + updateLine: (lineId: string, quantity: number) => Promise; + removeLine: (lineId: string) => Promise; + setEmail: (email: string) => Promise; + refreshCheckout: () => Promise; + toggleCart: () => void; + openCart: () => void; + closeCart: () => void; + clearError: () => void; + + // Getters + getLineCount: () => number; + getTotal: () => number; + getLines: () => CheckoutLine[]; +} + +export const useSaleorCheckoutStore = create()( + persist( + (set, get) => ({ + checkout: null, + checkoutToken: null, + isOpen: false, + isLoading: false, + error: null, + + initCheckout: async () => { + const { checkoutToken } = get(); + + if (checkoutToken) { + // Try to fetch existing checkout + try { + const { data } = await saleorClient.query({ + query: GET_CHECKOUT, + variables: { token: checkoutToken }, + }); + + if (data?.checkout) { + set({ checkout: data.checkout }); + return; + } + } catch (e) { + // Checkout not found or expired, create new one + } + } + + // Create new checkout + try { + const { data } = await saleorClient.mutate({ + mutation: CHECKOUT_CREATE, + variables: { + input: { + channel: CHANNEL, + lines: [], + }, + }, + }); + + if (data?.checkoutCreate?.checkout) { + set({ + checkout: data.checkoutCreate.checkout, + checkoutToken: data.checkoutCreate.checkout.token, + }); + } + } catch (e: any) { + set({ error: e.message }); + } + }, + + addLine: async (variantId: string, quantity: number) => { + set({ isLoading: true, error: null }); + + try { + let { checkout, checkoutToken } = get(); + + // Initialize checkout if needed + if (!checkout) { + await get().initCheckout(); + checkout = get().checkout; + checkoutToken = get().checkoutToken; + } + + if (!checkout) { + throw new Error("Failed to initialize checkout"); + } + + const { data } = await saleorClient.mutate({ + mutation: CHECKOUT_LINES_ADD, + variables: { + checkoutId: checkout.id, + lines: [{ variantId, quantity }], + }, + }); + + if (data?.checkoutLinesAdd?.checkout) { + set({ + checkout: data.checkoutLinesAdd.checkout, + isOpen: true, + isLoading: false, + }); + } else if (data?.checkoutLinesAdd?.errors?.length > 0) { + throw new Error(data.checkoutLinesAdd.errors[0].message); + } + } catch (e: any) { + set({ error: e.message, isLoading: false }); + } + }, + + updateLine: async (lineId: string, quantity: number) => { + set({ isLoading: true, error: null }); + + try { + const { checkout } = get(); + + if (!checkout) { + throw new Error("No active checkout"); + } + + if (quantity <= 0) { + // Remove line if quantity is 0 or less + await get().removeLine(lineId); + return; + } + + const { data } = await saleorClient.mutate({ + mutation: CHECKOUT_LINES_UPDATE, + variables: { + checkoutId: checkout.id, + lines: [{ lineId, quantity }], + }, + }); + + if (data?.checkoutLinesUpdate?.checkout) { + set({ + checkout: data.checkoutLinesUpdate.checkout, + isLoading: false, + }); + } else if (data?.checkoutLinesUpdate?.errors?.length > 0) { + throw new Error(data.checkoutLinesUpdate.errors[0].message); + } + } catch (e: any) { + set({ error: e.message, isLoading: false }); + } + }, + + removeLine: async (lineId: string) => { + set({ isLoading: true, error: null }); + + try { + const { checkout } = get(); + + if (!checkout) { + throw new Error("No active checkout"); + } + + const { data } = await saleorClient.mutate({ + mutation: CHECKOUT_LINES_DELETE, + variables: { + checkoutId: checkout.id, + lineIds: [lineId], + }, + }); + + if (data?.checkoutLinesDelete?.checkout) { + set({ + checkout: data.checkoutLinesDelete.checkout, + isLoading: false, + }); + } else if (data?.checkoutLinesDelete?.errors?.length > 0) { + throw new Error(data.checkoutLinesDelete.errors[0].message); + } + } catch (e: any) { + set({ error: e.message, isLoading: false }); + } + }, + + setEmail: async (email: string) => { + set({ isLoading: true, error: null }); + + try { + const { checkout } = get(); + + if (!checkout) { + throw new Error("No active checkout"); + } + + const { data } = await saleorClient.mutate({ + mutation: CHECKOUT_EMAIL_UPDATE, + variables: { + checkoutId: checkout.id, + email, + }, + }); + + if (data?.checkoutEmailUpdate?.checkout) { + set({ + checkout: data.checkoutEmailUpdate.checkout, + isLoading: false, + }); + } else if (data?.checkoutEmailUpdate?.errors?.length > 0) { + throw new Error(data.checkoutEmailUpdate.errors[0].message); + } + } catch (e: any) { + set({ error: e.message, isLoading: false }); + } + }, + + refreshCheckout: async () => { + const { checkoutToken } = get(); + + if (!checkoutToken) return; + + try { + const { data } = await saleorClient.query({ + query: GET_CHECKOUT, + variables: { token: checkoutToken }, + }); + + if (data?.checkout) { + set({ checkout: data.checkout }); + } + } catch (e) { + // Checkout might be expired + set({ checkout: null, checkoutToken: null }); + } + }, + + toggleCart: () => set((state) => ({ isOpen: !state.isOpen })), + openCart: () => set({ isOpen: true }), + closeCart: () => set({ isOpen: false }), + clearError: () => set({ error: null }), + + getLineCount: () => { + const { checkout } = get(); + if (!checkout?.lines) return 0; + return checkout.lines.reduce((count, line) => count + line.quantity, 0); + }, + + getTotal: () => { + const { checkout } = get(); + return checkout?.totalPrice?.gross?.amount || 0; + }, + + getLines: () => { + const { checkout } = get(); + return checkout?.lines || []; + }, + }), + { + name: "manoonoils-saleor-checkout", + partialize: (state) => ({ + checkoutToken: state.checkoutToken, + }), + } + ) +);