3 Commits

Author SHA1 Message Date
Unchained
d43481716d feat(saleor): Phase 4 - Checkout Flow
- Create checkout page with form validation
- Implement shipping/billing address forms
- Add Cash on Delivery (COD) payment method
- Integrate Saleor checkout completion mutation
- Add order success page with confirmation
- Handle checkout errors gracefully
- Display order summary with line items
2026-03-21 12:45:09 +02:00
Unchained
8b3389725e 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
2026-03-21 12:42:41 +02:00
Unchained
5706792980 feat(saleor): Phase 2 - Product Migration
- Update ProductCard to use Saleor Product type
- Update products listing page to fetch from Saleor
- Update product detail page with Saleor integration
- Add language switching support (SR/EN)
- Add SEO metadata generation
- Implement static params generation for all product slugs
- Add availability checking based on variant quantity
2026-03-21 12:38:24 +02:00
8 changed files with 1106 additions and 144 deletions

464
src/app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
import { saleorClient } from "@/lib/saleor/client";
import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_COMPLETE,
} from "@/lib/saleor/mutations/Checkout";
interface AddressForm {
firstName: string;
lastName: string;
streetAddress1: string;
streetAddress2: string;
city: string;
postalCode: string;
phone: string;
}
export default function CheckoutPage() {
const router = useRouter();
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orderComplete, setOrderComplete] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const [sameAsShipping, setSameAsShipping] = useState(true);
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const [billingAddress, setBillingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const lines = getLines();
const total = getTotal();
useEffect(() => {
if (!checkout) {
refreshCheckout();
}
}, [checkout, refreshCheckout]);
// Redirect if cart is empty
useEffect(() => {
if (lines.length === 0 && !orderComplete) {
// Optionally redirect to cart or products
// router.push("/products");
}
}, [lines, orderComplete, router]);
const handleShippingChange = (field: keyof AddressForm, value: string) => {
setShippingAddress((prev) => ({ ...prev, [field]: value }));
if (sameAsShipping) {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
}
};
const handleBillingChange = (field: keyof AddressForm, value: string) => {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!checkout) {
setError("No active checkout. Please try again.");
return;
}
setIsLoading(true);
setError(null);
try {
// Update shipping address
const shippingResult = await saleorClient.mutate({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
...shippingAddress,
country: "RS", // Serbia
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors?.length > 0) {
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
}
// Update billing address
const billingResult = await saleorClient.mutate({
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
billingAddress: {
...billingAddress,
country: "RS",
},
},
});
if (billingResult.data?.checkoutBillingAddressUpdate?.errors?.length > 0) {
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
}
// Complete checkout (creates order)
const completeResult = await saleorClient.mutate({
mutation: CHECKOUT_COMPLETE,
variables: {
checkoutId: checkout.id,
},
});
if (completeResult.data?.checkoutComplete?.errors?.length > 0) {
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
}
const order = completeResult.data?.checkoutComplete?.order;
if (order) {
setOrderNumber(order.number);
setOrderComplete(true);
} else {
throw new Error("Failed to create order");
}
} catch (err: any) {
setError(err.message || "An error occurred during checkout");
} finally {
setIsLoading(false);
}
};
// Order Success Page
if (orderComplete) {
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-3xl font-serif mb-2">Order Confirmed!</h1>
<p className="text-foreground-muted">Thank you for your purchase.</p>
</div>
{orderNumber && (
<div className="bg-background-ice p-6 rounded-lg mb-6">
<p className="text-sm text-foreground-muted mb-1">Order Number</p>
<p className="text-2xl font-serif">#{orderNumber}</p>
</div>
)}
<p className="text-foreground-muted mb-8">
You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.
</p>
<Link
href="/products"
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Continue Shopping
</Link>
</div>
</section>
<Footer />
</main>
);
}
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-serif mb-8">Checkout</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 mb-6 rounded">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Checkout Form */}
<div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shipping Address */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Shipping Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={shippingAddress.firstName}
onChange={(e) => handleShippingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={shippingAddress.lastName}
onChange={(e) => handleShippingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={shippingAddress.streetAddress1}
onChange={(e) => handleShippingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={shippingAddress.streetAddress2}
onChange={(e) => handleShippingChange("streetAddress2", e.target.value)}
placeholder="Apartment, suite, etc. (optional)"
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={shippingAddress.city}
onChange={(e) => handleShippingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={shippingAddress.postalCode}
onChange={(e) => handleShippingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={shippingAddress.phone}
onChange={(e) => handleShippingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
{/* Billing Address Toggle */}
<div className="border-b border-border pb-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={sameAsShipping}
onChange={(e) => setSameAsShipping(e.target.checked)}
className="w-4 h-4"
/>
<span>Billing address same as shipping</span>
</label>
</div>
{/* Billing Address (if different) */}
{!sameAsShipping && (
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Billing Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={billingAddress.firstName}
onChange={(e) => handleBillingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={billingAddress.lastName}
onChange={(e) => handleBillingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={billingAddress.streetAddress1}
onChange={(e) => handleBillingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={billingAddress.city}
onChange={(e) => handleBillingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={billingAddress.postalCode}
onChange={(e) => handleBillingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={billingAddress.phone}
onChange={(e) => handleBillingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
)}
{/* Payment Method */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Payment Method</h2>
<div className="bg-background-ice p-4 rounded">
<div className="flex items-center gap-3">
<input
type="radio"
checked
readOnly
className="w-4 h-4"
/>
<span>Cash on Delivery (COD)</span>
</div>
<p className="text-sm text-foreground-muted mt-2 ml-7">
Pay when your order is delivered to your door.
</p>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || lines.length === 0}
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{isLoading ? "Processing..." : `Complete Order - ${formatPrice(total)}`}
</button>
</form>
</div>
{/* Order Summary */}
<div className="bg-background-ice p-6 rounded-lg h-fit">
<h2 className="text-xl font-serif mb-6">Order Summary</h2>
{lines.length === 0 ? (
<p className="text-foreground-muted">Your cart is empty</p>
) : (
<>
<div className="space-y-4 mb-6">
{lines.map((line) => (
<div key={line.id} className="flex gap-4">
<div className="w-16 h-16 bg-white relative flex-shrink-0">
{line.variant.product.media[0]?.url && (
<Image
src={line.variant.product.media[0].url}
alt={line.variant.product.name}
fill
className="object-cover"
/>
)}
</div>
<div className="flex-1">
<h3 className="font-medium text-sm">{line.variant.product.name}</h3>
<p className="text-foreground-muted text-sm">
Qty: {line.quantity}
</p>
<p className="text-sm">
{formatPrice(line.totalPrice.gross.amount)}
</p>
</div>
</div>
))}
</div>
<div className="border-t border-border pt-4 space-y-2">
<div className="flex justify-between">
<span className="text-foreground-muted">Subtotal</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-muted">Shipping</span>
<span>
{checkout?.shippingPrice?.gross?.amount
? formatPrice(checkout.shippingPrice.gross.amount)
: "Calculated"
}
</span>
</div>
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
</>
)}
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -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 Footer from "@/components/layout/Footer";
import type { Product } from "@/types/saleor";
export const dynamic = 'force-dynamic';
// 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
interface ProductPageProps {
params: Promise<{ slug: string; locale?: string }>;
}
// 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) {
return (
<main className="min-h-screen">
<Header />
<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>
<Footer />
</main>
);
}
const image = product.images?.[0]?.src || '/placeholder.jpg';
const price = product.sale_price || product.price;
const localized = getLocalizedProduct(product, locale.toUpperCase());
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 (
<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">
<div className="max-w-7xl mx-auto">
<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">
<img
<Image
src={image}
alt={product.name}
className="object-cover w-full h-full"
alt={localized.name}
fill
className="object-cover"
priority
/>
</div>
{/* Product Info */}
<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
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>
{/* 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>

View File

@@ -1,4 +1,4 @@
import { getProducts } from "@/lib/woocommerce";
import { getProducts } from "@/lib/saleor";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
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.",
};
export default async function ProductsPage() {
let products: any[] = [];
try {
products = await getProducts();
} catch (e) {
console.log('Failed to fetch products during build');
interface ProductsPageProps {
params: Promise<{ locale: string }>;
}
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 (
<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">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
All Products
{locale === "en" ? "All Products" : "Svi Proizvodi"}
</h1>
{publishedProducts.length === 0 ? (
<p className="text-center text-foreground-muted">No products available</p>
{products.length === 0 ? (
<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">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
index={index}
locale={locale.toUpperCase()}
/>
))}
</div>
)}

View File

@@ -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 (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50 z-50"
initial={{ opacity: 0 }}
@@ -22,6 +44,8 @@ export default function CartDrawer() {
exit={{ opacity: 0 }}
onClick={closeCart}
/>
{/* Drawer */}
<motion.div
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%" }}
@@ -29,8 +53,11 @@ export default function CartDrawer() {
exit={{ x: "100%" }}
transition={{ type: "tween", duration: 0.3 }}
>
{/* Header */}
<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
onClick={closeCart}
className="p-2"
@@ -42,12 +69,26 @@ export default function CartDrawer() {
</button>
</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">
{items.length === 0 ? (
{lines.length === 0 ? (
<div className="text-center py-12">
<p className="text-foreground-muted mb-6">Your cart is empty</p>
<Link
href="/en/products"
href="/products"
onClick={closeCart}
className="inline-block px-6 py-3 bg-foreground text-white"
>
@@ -56,39 +97,53 @@ export default function CartDrawer() {
</div>
) : (
<div className="space-y-6">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
{lines.map((line) => (
<div key={line.id} className="flex gap-4">
{/* Product Image */}
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
{item.image && (
{line.variant.product.media[0]?.url && (
<Image
src={item.image}
alt={item.name}
src={line.variant.product.media[0].url}
alt={line.variant.product.name}
fill
className="object-cover"
/>
)}
</div>
{/* Product Info */}
<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">
{formatPrice(item.price)}
{formatPrice(
line.variant.pricing?.price?.gross?.amount || 0,
line.variant.pricing?.price?.gross?.currency
)}
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
onClick={() => updateLine(line.id, line.quantity - 1)}
disabled={isLoading}
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
>
-
</button>
<span>{item.quantity}</span>
<span>{line.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
onClick={() => updateLine(line.id, line.quantity + 1)}
disabled={isLoading}
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
onClick={() => removeLine(line.id)}
disabled={isLoading}
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">
@@ -103,18 +158,48 @@ export default function CartDrawer() {
)}
</div>
{items.length > 0 && (
{/* Footer with Checkout */}
{lines.length > 0 && (
<div className="p-6 border-t border-border/30">
<div className="flex items-center justify-between mb-4">
<span className="font-serif">Subtotal</span>
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
{/* Subtotal */}
<div className="flex items-center justify-between mb-2">
<span className="text-foreground-muted">Subtotal</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
<a
href="https://manoonoils.com/checkout"
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors"
{/* Shipping */}
<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
</a>
{isLoading ? "Processing..." : "Checkout"}
</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>
)}
</motion.div>

View File

@@ -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 (
<>

View File

@@ -3,15 +3,20 @@
import { motion } from "framer-motion";
import Image from "next/image";
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 {
product: WooProduct;
product: Product;
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 price = getProductPrice(product);
const localized = getLocalizedProduct(product, locale);
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
return (
<motion.div
@@ -20,29 +25,31 @@ export default function ProductCard({ product, index = 0 }: ProductCardProps) {
viewport={{ once: true }}
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">
{image && (
<Image
src={image}
alt={product.name}
alt={localized.name}
fill
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">
<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>
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
{product.name}
{localized.name}
</h3>
<p className="text-foreground-muted">
{product.price ? formatPrice(product.price) : "Contact for price"}
{price || (locale === "en" ? "Contact for price" : "Kontaktirajte za cenu")}
</p>
</Link>
</motion.div>

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>
))}
</div>
{/* SKU */}
{variant?.sku && (
<p className="text-sm text-foreground-muted mb-4">
SKU: {variant.sku}
</p>
)}
<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>
{/* 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>
)}
</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>

View 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,
}),
}
)
);