5 Commits

Author SHA1 Message Date
Unchained
ace1ac104e fix: cart delete mutation and console warnings
Some checks are pending
Build and Deploy / build (push) Waiting to run
- Fix checkoutLinesDelete mutation: use 'id' param and 'linesIds' instead of 'lineIds'
- Fix viewport metadata warning: move to separate viewport export in layout.tsx
- Add sizes prop to checkout Image with fill
- Fix CartDrawer init checkout useEffect to prevent re-render loops
- Various product detail improvements
2026-03-23 13:49:14 +02:00
Unchained
7f603c83e9 fix: correct checkoutLinesDelete parameter name lines -> lineIds
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-23 13:20:37 +02:00
Unchained
0e9ad28dcf Fix: remove priority attribute from regular img tag
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-23 11:18:29 +02:00
Unchained
70d6cfc9a7 Fix product images and add carousel; add transformation carousel on mobile
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-23 11:14:15 +02:00
Unchained
f3d60d3c5b Fix product images: use fill with aspect-square container
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-23 10:45:10 +02:00
10 changed files with 209 additions and 43 deletions

View File

@@ -442,6 +442,7 @@ export default function CheckoutPage() {
src={line.variant.product.media[0].url} src={line.variant.product.media[0].url}
alt={line.variant.product.name} alt={line.variant.product.name}
fill fill
sizes="64px"
className="object-cover" className="object-cover"
/> />
)} )}

View File

@@ -1,5 +1,5 @@
import "./globals.css"; import "./globals.css";
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import ErrorBoundary from "@/components/providers/ErrorBoundary"; import ErrorBoundary from "@/components/providers/ErrorBoundary";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -7,9 +7,8 @@ export const metadata: Metadata = {
default: "ManoonOils - Premium Natural Oils for Hair & Skin", default: "ManoonOils - Premium Natural Oils for Hair & Skin",
template: "%s | ManoonOils", template: "%s | ManoonOils",
}, },
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.", description: "Discover our premium collection of natural oils for hair and skin care.",
robots: "index, follow", robots: "index, follow",
viewport: "width=device-width, initial-scale=1, maximum-scale=5",
openGraph: { openGraph: {
title: "ManoonOils - Premium Natural Oils for Hair & Skin", title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care.", description: "Discover our premium collection of natural oils for hair and skin care.",
@@ -18,6 +17,12 @@ export const metadata: Metadata = {
}, },
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
// Suppress extension-caused hydration warnings // Suppress extension-caused hydration warnings
const suppressHydrationWarning = true; const suppressHydrationWarning = true;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } 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";
@@ -27,11 +27,15 @@ export default function CartDrawer() {
const lines = getLines(); const lines = getLines();
const total = getTotal(); const total = getTotal();
const lineCount = getLineCount(); const lineCount = getLineCount();
const [initialized, setInitialized] = useState(false);
// Initialize checkout on mount // Initialize checkout on mount (only once)
useEffect(() => { useEffect(() => {
if (!initialized) {
initCheckout(); initCheckout();
}, [initCheckout]); setInitialized(true);
}
}, [initialized]);
// Lock body scroll when cart is open // Lock body scroll when cart is open
useEffect(() => { useEffect(() => {

View File

@@ -125,6 +125,16 @@ function BeforeAfterSlider({ result }: { result: typeof results[0] }) {
} }
export default function BeforeAfterGallery() { export default function BeforeAfterGallery() {
const [selectedIndex, setSelectedIndex] = useState(0);
const goToPrev = () => {
setSelectedIndex(prev => prev === 0 ? results.length - 1 : prev - 1);
};
const goToNext = () => {
setSelectedIndex(prev => prev === results.length - 1 ? 0 : prev + 1);
};
return ( return (
<section className="py-24 bg-[#faf9f7]"> <section className="py-24 bg-[#faf9f7]">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
@@ -143,8 +153,8 @@ export default function BeforeAfterGallery() {
</h2> </h2>
</motion.div> </motion.div>
{/* Two transformations side by side */} {/* Desktop: Two transformations side by side */}
<div className="flex gap-6 max-w-6xl mx-auto"> <div className="hidden md:flex gap-6 max-w-6xl mx-auto">
{results.map((result, index) => ( {results.map((result, index) => (
<motion.div <motion.div
key={result.id} key={result.id}
@@ -159,6 +169,55 @@ export default function BeforeAfterGallery() {
))} ))}
</div> </div>
{/* Mobile: Carousel with one transformation at a time */}
<div className="md:hidden relative max-w-md mx-auto">
<div className="overflow-hidden">
<motion.div
key={selectedIndex}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<BeforeAfterSlider result={results[selectedIndex]} />
</motion.div>
</div>
{/* Carousel Navigation */}
<button
onClick={goToPrev}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
aria-label="Previous transformation"
>
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={goToNext}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
aria-label="Next transformation"
>
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Dot Indicators */}
<div className="flex justify-center gap-2 mt-6">
{results.map((_, index) => (
<button
key={index}
onClick={() => setSelectedIndex(index)}
className={`w-2 h-2 rounded-full transition-all ${
selectedIndex === index ? "bg-black w-4" : "bg-gray-300"
}`}
aria-label={`Go to transformation ${index + 1}`}
/>
))}
</div>
</div>
{/* CTA */} {/* CTA */}
<motion.div <motion.div
className="text-center mt-12" className="text-center mt-12"

View File

@@ -27,14 +27,12 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
> >
<Link href={`/products/${localized.slug}`} className="group block"> <Link href={`/products/${localized.slug}`} className="group block">
{/* Image Container */} {/* Image Container */}
<div className="relative aspect-square bg-[#f8f9fa] overflow-hidden mb-4"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? ( {image ? (
<Image <img
src={image} src={image}
alt={localized.name} alt={localized.name}
fill className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
sizes="100vw"
loading="lazy" loading="lazy"
/> />
) : ( ) : (

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Star, Minus, Plus } from "lucide-react"; import { ChevronDown, Star, Minus, Plus } from "lucide-react";
import type { Product } from "@/types/saleor"; import type { Product } from "@/types/saleor";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { getProductPrice, getLocalizedProduct } from "@/lib/saleor"; import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
import ProductCard from "@/components/product/ProductCard"; import ProductCard from "@/components/product/ProductCard";
import ProductBenefits from "@/components/product/ProductBenefits"; import ProductBenefits from "@/components/product/ProductBenefits";
import ProductReviews from "@/components/product/ProductReviews"; import ProductReviews from "@/components/product/ProductReviews";
@@ -91,8 +91,23 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0);
const { addLine, openCart } = useSaleorCheckoutStore(); const { addLine, openCart } = useSaleorCheckoutStore();
// Cycle through urgency messages
useEffect(() => {
const interval = setInterval(() => {
setUrgencyIndex(prev => (prev + 1) % 3);
}, 3000);
return () => clearInterval(interval);
}, []);
const urgencyMessages = [
{ icon: "🚀", text: "Hurry up! 500+ items sold in the last 3 days!" },
{ icon: "🛒", text: "In the carts of 2.5K people - buy before its gone!" },
{ icon: "👀", text: "7,562 people viewed this product in the last 24 hours!" },
];
const localized = getLocalizedProduct(product, locale); const localized = getLocalizedProduct(product, locale);
const variant = product.variants?.[0]; const variant = product.variants?.[0];
@@ -115,6 +130,8 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
const isAvailable = variant?.quantityAvailable > 0; const isAvailable = variant?.quantityAvailable > 0;
const price = getProductPrice(product); const price = getProductPrice(product);
const priceAmount = getProductPriceAmount(product);
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
// Extract short description (first sentence or first 100 chars) // Extract short description (first sentence or first 100 chars)
const shortDescription = localized.description const shortDescription = localized.description
@@ -152,7 +169,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
className="flex gap-4" className="flex flex-col md:flex-row gap-4"
> >
{/* Thumbnails - Vertical on Desktop, Hidden on Mobile */} {/* Thumbnails - Vertical on Desktop, Hidden on Mobile */}
{images.length > 1 && ( {images.length > 1 && (
@@ -167,29 +184,63 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
: "border-transparent hover:border-[#999999]" : "border-transparent hover:border-[#999999]"
}`} }`}
> >
<Image <img
src={image.url} src={image.url}
alt={image.alt || localized.name} alt={image.alt || localized.name}
fill className="w-full h-full object-cover"
className="object-cover"
sizes="80px"
/> />
</button> </button>
))} ))}
</div> </div>
)} )}
{/* Main Image */} {/* Main Image */}
<div className="relative bg-[#f8f9fa] overflow-hidden"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
{images[selectedImage] && ( <img
<Image
src={images[selectedImage].url} src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name} alt={images[selectedImage].alt || localized.name}
width={600} className="w-full h-full object-cover"
height={600}
className="w-full h-auto object-cover"
priority
/> />
{/* Carousel Navigation - Mobile Only */}
{images.length > 1 && (
<>
{/* Left Arrow */}
<button
onClick={() => setSelectedImage(prev => prev === 0 ? images.length - 1 : prev - 1)}
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 hover:bg-white rounded-full flex items-center justify-center shadow-md transition-all hover:scale-110 md:hidden"
aria-label="Previous image"
>
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Right Arrow */}
<button
onClick={() => setSelectedImage(prev => prev === images.length - 1 ? 0 : prev + 1)}
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 hover:bg-white rounded-full flex items-center justify-center shadow-md transition-all hover:scale-110 md:hidden"
aria-label="Next image"
>
<svg className="w-5 h-5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Dot Indicators - Mobile Only */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 md:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => setSelectedImage(index)}
className={`w-2 h-2 rounded-full transition-all ${
selectedImage === index ? "bg-white w-4" : "bg-white/50"
}`}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
</>
)} )}
</div> </div>
</motion.div> </motion.div>
@@ -201,23 +252,64 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8" className="lg:pl-8"
> >
{/* Urgency Sales Banner */}
<motion.div
key={urgencyIndex}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
>
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text}
</motion.div>
{/* Product Name */} {/* Product Name */}
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight"> <h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name} {localized.name}
</h1> </h1>
{/* Short Description */} {/* Short Description */}
<p className="text-[#666666] leading-relaxed mb-6"> <p className="text-[#666666] leading-relaxed mb-4">
{shortDescription} {shortDescription}
</p> </p>
{/* Stock Warning - Static */}
<div className="flex items-center justify-start gap-2 mb-6">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
<span className="text-red-600 text-sm font-medium">Stocks are running out!</span>
</div>
{/* Discount Price Display */}
{originalPrice && priceAmount > 0 && (
<div className="mb-4">
<div className="flex items-center gap-3 mb-2">
<span className="text-xl text-[#666666] line-through">
{originalPrice}
</span>
<span className="bg-[#b91c1c] text-white text-xs font-bold px-2 py-1 rounded">
-30%
</span>
</div>
<span className="text-3xl font-bold text-[#b91c1c]">
{price}
</span>
</div>
)}
{/* Price & Rating */} {/* Price & Rating */}
{!originalPrice && (
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<span className="text-3xl font-medium"> <span className="text-3xl font-medium">
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")} {price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
</span> </span>
<StarRating rating={5} count={1000} /> <StarRating rating={5} count={1000} />
</div> </div>
)}
{/* Divider */} {/* Divider */}
<div className="border-t border-[#e5e5e5] mb-8" /> <div className="border-t border-[#e5e5e5] mb-8" />

View File

@@ -28,6 +28,7 @@ export {
getProducts, getProducts,
getProductBySlug, getProductBySlug,
getProductPrice, getProductPrice,
getProductPriceAmount,
getProductImage, getProductImage,
isProductAvailable, isProductAvailable,
formatPrice, formatPrice,

View File

@@ -50,8 +50,8 @@ export const CHECKOUT_LINES_UPDATE = gql`
`; `;
export const CHECKOUT_LINES_DELETE = gql` export const CHECKOUT_LINES_DELETE = gql`
mutation CheckoutLinesDelete($checkoutId: ID!, $lineIds: [ID!]!) { mutation CheckoutLinesDelete($id: ID!, $linesIds: [ID!]!) {
checkoutLinesDelete(checkoutId: $checkoutId, lines: $lineIds) { checkoutLinesDelete(id: $id, linesIds: $linesIds) {
checkout { checkout {
...CheckoutFragment ...CheckoutFragment
} }

View File

@@ -68,6 +68,11 @@ export function getProductPrice(product: Product): string {
); );
} }
export function getProductPriceAmount(product: Product): number {
const variant = product.variants?.[0];
return variant?.pricing?.price?.gross?.amount || 0;
}
export function getProductImage(product: Product): string { export function getProductImage(product: Product): string {
if (product.media && product.media.length > 0) { if (product.media && product.media.length > 0) {
return product.media[0].url; return product.media[0].url;
@@ -88,7 +93,8 @@ export function formatPrice(amount: number, currency: string = "RSD"): string {
return new Intl.NumberFormat("sr-RS", { return new Intl.NumberFormat("sr-RS", {
style: "currency", style: "currency",
currency: currency, currency: currency,
minimumFractionDigits: 0, minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount); }).format(amount);
} }

View File

@@ -221,8 +221,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
const { data } = await saleorClient.mutate<CheckoutLinesDeleteResponse>({ const { data } = await saleorClient.mutate<CheckoutLinesDeleteResponse>({
mutation: CHECKOUT_LINES_DELETE, mutation: CHECKOUT_LINES_DELETE,
variables: { variables: {
checkoutId: checkout.id, id: checkout.id,
lineIds: [lineId], linesIds: [lineId],
}, },
}); });