Compare commits
2 Commits
feature/00
...
feature/00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3389725e | ||
|
|
5706792980 |
@@ -1,37 +1,93 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
import Image from "next/image";
|
||||||
|
import { getProductBySlug, getProducts, getProductPrice, getProductImage, getLocalizedProduct, formatPrice } from "@/lib/saleor";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
interface ProductPageProps {
|
||||||
|
params: Promise<{ slug: string; locale?: string }>;
|
||||||
// Disable static generation - this page will be server-rendered
|
|
||||||
export const generateStaticParams = undefined;
|
|
||||||
|
|
||||||
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
||||||
const { slug } = await params;
|
|
||||||
let product = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const products = await getProducts();
|
|
||||||
product = products.find((p) => (p.slug || p.id.toString()) === slug);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate static params for all products
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
|
const products = await getProducts("SR", 100);
|
||||||
|
const params: Array<{ slug: string; locale: string }> = [];
|
||||||
|
|
||||||
|
products.forEach((product: Product) => {
|
||||||
|
// Serbian slug
|
||||||
|
params.push({ slug: product.slug, locale: "sr" });
|
||||||
|
|
||||||
|
// English slug (if translation exists)
|
||||||
|
if (product.translation?.slug) {
|
||||||
|
params.push({ slug: product.translation.slug, locale: "en" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return params;
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: ProductPageProps) {
|
||||||
|
const { slug, locale = "sr" } = await params;
|
||||||
|
const product = await getProductBySlug(slug, locale.toUpperCase());
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
title: locale === "en" ? "Product Not Found" : "Proizvod nije pronađen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const localized = getLocalizedProduct(product, locale.toUpperCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: localized.name,
|
||||||
|
description: localized.seoDescription || localized.description?.slice(0, 160),
|
||||||
|
alternates: {
|
||||||
|
canonical: `/products/${product.slug}`,
|
||||||
|
languages: {
|
||||||
|
"sr": `/products/${product.slug}`,
|
||||||
|
"en": product.translation?.slug ? `/products/${product.translation.slug}` : `/products/${product.slug}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
|
const { slug, locale = "sr" } = await params;
|
||||||
|
const product = await getProductBySlug(slug, locale.toUpperCase());
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="pt-24 text-center">
|
<div className="pt-24 text-center">
|
||||||
<h1 className="text-2xl">Product not found</h1>
|
<h1 className="text-2xl">
|
||||||
|
{locale === "en" ? "Product not found" : "Proizvod nije pronađen"}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = product.images?.[0]?.src || '/placeholder.jpg';
|
const localized = getLocalizedProduct(product, locale.toUpperCase());
|
||||||
const price = product.sale_price || product.price;
|
const image = getProductImage(product);
|
||||||
|
const price = getProductPrice(product);
|
||||||
|
const variant = product.variants?.[0];
|
||||||
|
const isAvailable = variant?.quantityAvailable > 0;
|
||||||
|
|
||||||
|
// Determine language based on which slug matched
|
||||||
|
const isEnglishSlug = slug === product.translation?.slug;
|
||||||
|
const currentLocale = isEnglishSlug ? "en" : "sr";
|
||||||
|
|
||||||
|
// URLs for language switcher
|
||||||
|
const serbianUrl = `/products/${product.slug}`;
|
||||||
|
const englishUrl = product.translation?.slug
|
||||||
|
? `/products/${product.translation.slug}`
|
||||||
|
: serbianUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
@@ -40,26 +96,70 @@ export default async function ProductPage({ params }: { params: Promise<{ slug:
|
|||||||
<section className="pt-24 pb-20 px-4">
|
<section className="pt-24 pb-20 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
|
{/* Product Image */}
|
||||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
|
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={product.name}
|
alt={localized.name}
|
||||||
className="object-cover w-full h-full"
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
|
<h1 className="text-4xl font-serif mb-4">{localized.name}</h1>
|
||||||
|
|
||||||
<div className="text-2xl mb-6">{price} RSD</div>
|
{price && (
|
||||||
|
<div className="text-2xl mb-6">{price}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
|
{localized.description && (
|
||||||
|
<div
|
||||||
|
className="prose max-w-none mb-8"
|
||||||
|
dangerouslySetInnerHTML={{ __html: localized.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add to Cart Button */}
|
||||||
<button
|
<button
|
||||||
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
|
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!isAvailable}
|
||||||
>
|
>
|
||||||
Add to Cart
|
{isAvailable
|
||||||
|
? (currentLocale === "en" ? "Add to Cart" : "Dodaj u korpu")
|
||||||
|
: (currentLocale === "en" ? "Out of Stock" : "Nema na stanju")
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* SKU */}
|
||||||
|
{variant?.sku && (
|
||||||
|
<p className="mt-4 text-sm text-foreground-muted">
|
||||||
|
SKU: {variant.sku}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="mt-8 pt-8 border-t">
|
||||||
|
<p className="text-sm text-foreground-muted mb-2">
|
||||||
|
{currentLocale === "en" ? "Language:" : "Jezik:"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<a
|
||||||
|
href={serbianUrl}
|
||||||
|
className={`text-sm font-medium ${currentLocale === "sr" ? "text-foreground" : "text-foreground-muted hover:text-foreground"}`}
|
||||||
|
>
|
||||||
|
🇷🇸 Srpski
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={englishUrl}
|
||||||
|
className={`text-sm font-medium ${currentLocale === "en" ? "text-foreground" : "text-foreground-muted hover:text-foreground"}`}
|
||||||
|
>
|
||||||
|
🇬🇧 English
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/woocommerce";
|
import { getProducts } from "@/lib/saleor";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
import ProductCard from "@/components/product/ProductCard";
|
||||||
@@ -8,15 +8,13 @@ export const metadata = {
|
|||||||
description: "Browse our collection of premium natural oils for hair and skin care.",
|
description: "Browse our collection of premium natural oils for hair and skin care.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProductsPage() {
|
interface ProductsPageProps {
|
||||||
let products: any[] = [];
|
params: Promise<{ locale: string }>;
|
||||||
try {
|
|
||||||
products = await getProducts();
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Failed to fetch products during build');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishedProducts = products.filter((p) => p.status === "publish");
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
|
const { locale = "sr" } = await params;
|
||||||
|
const products = await getProducts(locale.toUpperCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen pt-16 md:pt-20">
|
<main className="min-h-screen pt-16 md:pt-20">
|
||||||
@@ -25,15 +23,22 @@ export default async function ProductsPage() {
|
|||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
|
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
|
||||||
All Products
|
{locale === "en" ? "All Products" : "Svi Proizvodi"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{publishedProducts.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<p className="text-center text-foreground-muted">No products available</p>
|
<p className="text-center text-foreground-muted">
|
||||||
|
{locale === "en" ? "No products available" : "Nema dostupnih proizvoda"}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{publishedProducts.map((product, index) => (
|
{products.map((product, index) => (
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
index={index}
|
||||||
|
locale={locale.toUpperCase()}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { formatPrice } from "@/lib/woocommerce";
|
import { formatPrice } from "@/lib/saleor";
|
||||||
|
|
||||||
export default function CartDrawer() {
|
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 total = getTotal();
|
||||||
|
const lineCount = getLineCount();
|
||||||
|
|
||||||
|
// Initialize checkout on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initCheckout();
|
||||||
|
}, [initCheckout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 bg-black/50 z-50"
|
className="fixed inset-0 bg-black/50 z-50"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -22,6 +44,8 @@ export default function CartDrawer() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed top-0 right-0 bottom-0 w-full max-w-md bg-white z-50 shadow-xl flex flex-col"
|
className="fixed top-0 right-0 bottom-0 w-full max-w-md bg-white z-50 shadow-xl flex flex-col"
|
||||||
initial={{ x: "100%" }}
|
initial={{ x: "100%" }}
|
||||||
@@ -29,8 +53,11 @@ export default function CartDrawer() {
|
|||||||
exit={{ x: "100%" }}
|
exit={{ x: "100%" }}
|
||||||
transition={{ type: "tween", duration: 0.3 }}
|
transition={{ type: "tween", duration: 0.3 }}
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border/30">
|
<div className="flex items-center justify-between p-6 border-b border-border/30">
|
||||||
<h2 className="text-xl font-serif">Your Cart</h2>
|
<h2 className="text-xl font-serif">
|
||||||
|
Your Cart ({lineCount})
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
className="p-2"
|
className="p-2"
|
||||||
@@ -42,12 +69,26 @@ export default function CartDrawer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border-b border-red-100">
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-red-600 text-xs underline mt-1"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cart Items */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{items.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
<p className="text-foreground-muted mb-6">Your cart is empty</p>
|
||||||
<Link
|
<Link
|
||||||
href="/en/products"
|
href="/products"
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
className="inline-block px-6 py-3 bg-foreground text-white"
|
className="inline-block px-6 py-3 bg-foreground text-white"
|
||||||
>
|
>
|
||||||
@@ -56,39 +97,53 @@ export default function CartDrawer() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{items.map((item) => (
|
{lines.map((line) => (
|
||||||
<div key={item.id} className="flex gap-4">
|
<div key={line.id} className="flex gap-4">
|
||||||
|
{/* Product Image */}
|
||||||
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
|
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
|
||||||
{item.image && (
|
{line.variant.product.media[0]?.url && (
|
||||||
<Image
|
<Image
|
||||||
src={item.image}
|
src={line.variant.product.media[0].url}
|
||||||
alt={item.name}
|
alt={line.variant.product.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-serif text-sm">{item.name}</h3>
|
<h3 className="font-serif text-sm">{line.variant.product.name}</h3>
|
||||||
|
{line.variant.name !== "Default" && (
|
||||||
|
<p className="text-foreground-muted text-xs">{line.variant.name}</p>
|
||||||
|
)}
|
||||||
<p className="text-foreground-muted text-sm mt-1">
|
<p className="text-foreground-muted text-sm mt-1">
|
||||||
{formatPrice(item.price)}
|
{formatPrice(
|
||||||
|
line.variant.pricing?.price?.gross?.amount || 0,
|
||||||
|
line.variant.pricing?.price?.gross?.currency
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Quantity Controls */}
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
onClick={() => updateLine(line.id, line.quantity - 1)}
|
||||||
className="w-8 h-8 border border-border flex items-center justify-center"
|
disabled={isLoading}
|
||||||
|
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span>{item.quantity}</span>
|
<span>{line.quantity}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
onClick={() => updateLine(line.id, line.quantity + 1)}
|
||||||
className="w-8 h-8 border border-border flex items-center justify-center"
|
disabled={isLoading}
|
||||||
|
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => removeLine(line.id)}
|
||||||
|
disabled={isLoading}
|
||||||
className="ml-auto text-foreground-muted hover:text-red-500"
|
className="ml-auto text-foreground-muted hover:text-red-500"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -103,18 +158,48 @@ export default function CartDrawer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{/* Footer with Checkout */}
|
||||||
|
{lines.length > 0 && (
|
||||||
<div className="p-6 border-t border-border/30">
|
<div className="p-6 border-t border-border/30">
|
||||||
<div className="flex items-center justify-between mb-4">
|
{/* Subtotal */}
|
||||||
<span className="font-serif">Subtotal</span>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
|
<span className="text-foreground-muted">Subtotal</span>
|
||||||
|
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
href="https://manoonoils.com/checkout"
|
{/* Shipping */}
|
||||||
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors"
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-foreground-muted">Shipping</span>
|
||||||
|
<span>
|
||||||
|
{checkout?.shippingPrice?.gross?.amount
|
||||||
|
? formatPrice(checkout.shippingPrice.gross.amount)
|
||||||
|
: "Calculated at checkout"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="flex items-center justify-between mb-4 pt-4 border-t border-border/30">
|
||||||
|
<span className="font-serif">Total</span>
|
||||||
|
<span className="font-serif text-lg">{formatPrice(total)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkout Button */}
|
||||||
|
<Link
|
||||||
|
href="/checkout"
|
||||||
|
onClick={closeCart}
|
||||||
|
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Checkout
|
{isLoading ? "Processing..." : "Checkout"}
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
|
{/* Continue Shopping */}
|
||||||
|
<button
|
||||||
|
onClick={closeCart}
|
||||||
|
className="block w-full py-3 text-center text-foreground-muted hover:text-foreground mt-2"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { User, ShoppingBag, Menu } from "lucide-react";
|
import { User, ShoppingBag, Menu } from "lucide-react";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import CartDrawer from "@/components/cart/CartDrawer";
|
import CartDrawer from "@/components/cart/CartDrawer";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: WooProduct;
|
product: Product;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
|
||||||
const image = getProductImage(product);
|
const image = getProductImage(product);
|
||||||
|
const price = getProductPrice(product);
|
||||||
|
const localized = getLocalizedProduct(product, locale);
|
||||||
|
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -20,29 +25,31 @@ export default function ProductCard({ product, index = 0 }: ProductCardProps) {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
>
|
>
|
||||||
<Link href={`/products/${product.slug}`} className="group block">
|
<Link href={`/products/${localized.slug}`} className="group block">
|
||||||
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
|
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
|
||||||
{image && (
|
{image && (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={product.name}
|
alt={localized.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{product.stock_status === "outofstock" && (
|
{!isAvailable && (
|
||||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
<span className="text-white font-medium">Out of Stock</span>
|
<span className="text-white font-medium">
|
||||||
|
{locale === "en" ? "Out of Stock" : "Nema na stanju"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
|
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
|
||||||
{product.name}
|
{localized.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-foreground-muted">
|
<p className="text-foreground-muted">
|
||||||
{product.price ? formatPrice(product.price) : "Contact for price"}
|
{price || (locale === "en" ? "Contact for price" : "Kontaktirajte za cenu")}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -3,45 +3,55 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
|
import type { Product } from "@/types/saleor";
|
||||||
import { useCartStore } from "@/stores/cartStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
|
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
import ProductCard from "@/components/product/ProductCard";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: WooProduct;
|
product: Product;
|
||||||
relatedProducts: WooProduct[];
|
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 [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details");
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const addItem = useCartStore((state) => state.addItem);
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
const images = product.images?.length > 0
|
const localized = getLocalizedProduct(product, locale);
|
||||||
? product.images
|
const variant = product.variants?.[0];
|
||||||
: [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }];
|
const images = product.media?.length > 0
|
||||||
|
? product.media
|
||||||
|
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" }];
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const handleAddToCart = async () => {
|
||||||
addItem({
|
if (!variant?.id) return;
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
setIsAdding(true);
|
||||||
price: product.price || product.regular_price,
|
try {
|
||||||
quantity,
|
await addLine(variant.id, quantity);
|
||||||
image: images[0]?.src || "",
|
openCart();
|
||||||
sku: product.sku || "",
|
} finally {
|
||||||
});
|
setIsAdding(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripHtml = (html: string) => {
|
const stripHtml = (html: string) => {
|
||||||
|
if (!html) return "";
|
||||||
return html.replace(/<[^>]*>/g, "");
|
return html.replace(/<[^>]*>/g, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAvailable = variant?.quantityAvailable > 0;
|
||||||
|
const price = getProductPrice(product);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="py-12 md:py-20 px-4">
|
<section className="py-12 md:py-20 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
|
{/* Product Images */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
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">
|
<div className="relative aspect-square bg-background-ice mb-4">
|
||||||
{images[selectedImage] && (
|
{images[selectedImage] && (
|
||||||
<Image
|
<Image
|
||||||
src={images[selectedImage].src}
|
src={images[selectedImage].url}
|
||||||
alt={images[selectedImage].alt || product.name}
|
alt={images[selectedImage].alt || localized.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
@@ -70,8 +80,8 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={image.src}
|
src={image.url}
|
||||||
alt={image.alt || product.name}
|
alt={image.alt || localized.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -81,97 +91,102 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
|
|||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<h1 className="text-3xl md:text-4xl font-serif mb-4">
|
<h1 className="text-3xl md:text-4xl font-serif mb-4">
|
||||||
{product.name}
|
{localized.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-2xl text-foreground-muted mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
|
{/* Short Description */}
|
||||||
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
|
<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>
|
</div>
|
||||||
|
|
||||||
{product.stock_status === "instock" ? (
|
{/* Add to Cart */}
|
||||||
|
{isAvailable ? (
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
{/* Quantity Selector */}
|
||||||
<div className="flex items-center border border-border">
|
<div className="flex items-center border border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||||
className="px-4 py-3"
|
className="px-4 py-3 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span className="px-4 py-3">{quantity}</span>
|
<span className="px-4 py-3 min-w-[3rem] text-center">{quantity}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setQuantity(quantity + 1)}
|
onClick={() => setQuantity(quantity + 1)}
|
||||||
className="px-4 py-3"
|
className="px-4 py-3 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add to Cart Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-border/30">
|
{/* SKU */}
|
||||||
<div className="flex border-b border-border/30">
|
{variant?.sku && (
|
||||||
{(["details", "ingredients", "usage"] as const).map((tab) => (
|
<p className="text-sm text-foreground-muted mb-4">
|
||||||
<button
|
SKU: {variant.sku}
|
||||||
key={tab}
|
</p>
|
||||||
onClick={() => setActiveTab(tab)}
|
)}
|
||||||
className={`flex-1 py-4 font-medium capitalize ${
|
|
||||||
activeTab === tab
|
|
||||||
? "border-b-2 border-foreground"
|
|
||||||
: "text-foreground-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="py-6 text-foreground-muted">
|
{/* Full Description */}
|
||||||
{activeTab === "details" && (
|
{localized.description && (
|
||||||
<p>{stripHtml(product.description)}</p>
|
<div className="border-t border-border/30 pt-6">
|
||||||
)}
|
<h3 className="font-serif text-lg mb-4">
|
||||||
{activeTab === "ingredients" && (
|
{locale === "EN" ? "Description" : "Opis"}
|
||||||
<p>Natural ingredients - Contact for detailed information.</p>
|
</h3>
|
||||||
)}
|
<div
|
||||||
{activeTab === "usage" && (
|
className="prose max-w-none text-foreground-muted"
|
||||||
<p>Apply to clean skin or hair. Use daily for best results.</p>
|
dangerouslySetInnerHTML={{ __html: localized.description }}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Related Products */}
|
||||||
{relatedProducts.length > 0 && (
|
{relatedProducts.length > 0 && (
|
||||||
<section className="py-12 px-4 bg-background-ice">
|
<section className="py-12 px-4 bg-background-ice">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h2 className="text-2xl font-serif text-center mb-8">
|
<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>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{relatedProducts.map((product, index) => (
|
{relatedProducts.map((relatedProduct, index) => (
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
<ProductCard
|
||||||
|
key={relatedProduct.id}
|
||||||
|
product={relatedProduct}
|
||||||
|
index={index}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
281
src/stores/saleorCheckoutStore.ts
Normal file
281
src/stores/saleorCheckoutStore.ts
Normal file
@@ -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<void>;
|
||||||
|
addLine: (variantId: string, quantity: number) => Promise<void>;
|
||||||
|
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||||
|
removeLine: (lineId: string) => Promise<void>;
|
||||||
|
setEmail: (email: string) => Promise<void>;
|
||||||
|
refreshCheckout: () => Promise<void>;
|
||||||
|
toggleCart: () => void;
|
||||||
|
openCart: () => void;
|
||||||
|
closeCart: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getLineCount: () => number;
|
||||||
|
getTotal: () => number;
|
||||||
|
getLines: () => CheckoutLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user