feat(saleor): Phase 3 - Cart Migration

- Create Saleor checkout store (Zustand + persist)
- Update CartDrawer to use Saleor checkout
- Update Header to use Saleor checkout store
- Update ProductDetail with Add to Cart functionality
- Add checkout initialization on app load
- Handle checkout line add/update/delete operations
- Add error handling and loading states
This commit is contained in:
Unchained
2026-03-21 12:42:41 +02:00
parent 5706792980
commit 8b3389725e
4 changed files with 482 additions and 96 deletions

View File

@@ -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 (
<>
<section className="py-12 md:py-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Product Images */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
@@ -50,8 +60,8 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
<div className="relative aspect-square bg-background-ice mb-4">
{images[selectedImage] && (
<Image
src={images[selectedImage].src}
alt={images[selectedImage].alt || product.name}
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
fill
className="object-cover"
priority
@@ -70,8 +80,8 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
}`}
>
<Image
src={image.src}
alt={image.alt || product.name}
src={image.url}
alt={image.alt || localized.name}
fill
className="object-cover"
/>
@@ -81,97 +91,102 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
)}
</motion.div>
{/* Product Info */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h1 className="text-3xl md:text-4xl font-serif mb-4">
{product.name}
{localized.name}
</h1>
<p className="text-2xl text-foreground-muted mb-6">
{product.price ? formatPrice(product.price) : "Contact for price"}
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
</p>
{/* Short Description */}
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
<p>{stripHtml(product.short_description || product.description.slice(0, 200))}</p>
<p>{stripHtml(localized.description).slice(0, 200)}...</p>
</div>
{product.stock_status === "instock" ? (
{/* Add to Cart */}
{isAvailable ? (
<div className="flex items-center gap-4 mb-8">
{/* Quantity Selector */}
<div className="flex items-center border border-border">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="px-4 py-3"
className="px-4 py-3 hover:bg-gray-50"
>
-
</button>
<span className="px-4 py-3">{quantity}</span>
<span className="px-4 py-3 min-w-[3rem] text-center">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="px-4 py-3"
className="px-4 py-3 hover:bg-gray-50"
>
+
</button>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
disabled={isAdding}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors disabled:opacity-50"
>
Add to Cart
{isAdding
? (locale === "EN" ? "Adding..." : "Dodavanje...")
: (locale === "EN" ? "Add to Cart" : "Dodaj u korpu")
}
</button>
</div>
) : (
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
Out of Stock
{locale === "EN" ? "Out of Stock" : "Nema na stanju"}
</div>
)}
<div className="border-t border-border/30">
<div className="flex border-b border-border/30">
{(["details", "ingredients", "usage"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-4 font-medium capitalize ${
activeTab === tab
? "border-b-2 border-foreground"
: "text-foreground-muted"
}`}
>
{tab}
</button>
))}
{/* SKU */}
{variant?.sku && (
<p className="text-sm text-foreground-muted mb-4">
SKU: {variant.sku}
</p>
)}
{/* Full Description */}
{localized.description && (
<div className="border-t border-border/30 pt-6">
<h3 className="font-serif text-lg mb-4">
{locale === "EN" ? "Description" : "Opis"}
</h3>
<div
className="prose max-w-none text-foreground-muted"
dangerouslySetInnerHTML={{ __html: localized.description }}
/>
</div>
<div className="py-6 text-foreground-muted">
{activeTab === "details" && (
<p>{stripHtml(product.description)}</p>
)}
{activeTab === "ingredients" && (
<p>Natural ingredients - Contact for detailed information.</p>
)}
{activeTab === "usage" && (
<p>Apply to clean skin or hair. Use daily for best results.</p>
)}
</div>
</div>
)}
</motion.div>
</div>
</div>
</section>
{/* Related Products */}
{relatedProducts.length > 0 && (
<section className="py-12 px-4 bg-background-ice">
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-serif text-center mb-8">
You May Also Like
{locale === "EN" ? "You May Also Like" : "Možda će vam se svideti"}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{relatedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
{relatedProducts.map((relatedProduct, index) => (
<ProductCard
key={relatedProduct.id}
product={relatedProduct}
index={index}
locale={locale}
/>
))}
</div>
</div>