Redesign phase 1: Homepage polish and design system foundation

- Fix newsletter subscribe box centering on homepage
- Fix header overlap on product pages (pt-[72px] instead of pt-[100px])
- Add scroll-mt-[72px] for smooth scroll anchor offset
- Add HeroVideo component with video hero placeholder
- Add REDESIGN_SPECIFICATION.md with 9-phase design plan
- Clean up globals.css theme declarations and comments
- Update Header with improved sticky behavior and cart
- Update ProductDetail with better layout and spacing
- Update CartDrawer with improved slide-out cart UI
- Add English translations for updated pages
- Various CSS refinements across pages
This commit is contained in:
Unchained
2026-03-21 16:22:17 +02:00
parent 9d639fbd64
commit 7c05bd2346
22 changed files with 2653 additions and 884 deletions

View File

@@ -2,10 +2,12 @@
import { useState } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, Star, Minus, Plus } from "lucide-react";
import type { Product } from "@/types/saleor";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import { getProductPrice, getLocalizedProduct } from "@/lib/saleor";
import ProductCard from "@/components/product/ProductCard";
interface ProductDetailProps {
@@ -14,6 +16,70 @@ interface ProductDetailProps {
locale?: string;
}
// Expandable Section Component
function ExpandableSection({
title,
children,
defaultOpen = false
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border-b border-[#e5e5e5]">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full py-5 flex items-center justify-between text-left group"
>
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{title}
</span>
<ChevronDown
className={`w-5 h-5 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
strokeWidth={1.5}
/>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
>
<div className="pb-6 text-[#666666] text-sm leading-relaxed">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// Star Rating Component
function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number }) {
return (
<div className="flex items-center gap-1">
<div className="flex">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${i < rating ? 'fill-black text-black' : 'text-[#e5e5e5]'}`}
/>
))}
</div>
{count > 0 && (
<span className="text-sm text-[#666666] ml-1">({count})</span>
)}
</div>
);
}
export default function ProductDetail({ product, relatedProducts, locale = "SR" }: ProductDetailProps) {
const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1);
@@ -22,9 +88,11 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
const localized = getLocalizedProduct(product, locale);
const variant = product.variants?.[0];
// Get all images from media
const images = product.media?.length > 0
? product.media
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" }];
? product.media.filter(m => m.type === "IMAGE")
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
const handleAddToCart = async () => {
if (!variant?.id) return;
@@ -38,45 +106,58 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
}
};
const stripHtml = (html: string) => {
if (!html) return "";
return html.replace(/<[^>]*>/g, "");
};
const isAvailable = variant?.quantityAvailable > 0;
const price = getProductPrice(product);
// Extract short description (first sentence or first 100 chars)
const shortDescription = localized.description
? localized.description.split('.')[0] + '.'
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
// Parse benefits from product metadata or use defaults
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [
locale === "EN" ? "Natural" : "Prirodno",
locale === "EN" ? "Organic" : "Organsko",
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti",
];
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 */}
<section className="min-h-screen" id="product-detail">
{/* Breadcrumb - with proper top padding for fixed header */}
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
<div className="container py-5">
<nav className="flex items-center gap-2 text-sm">
<Link href="/" className="text-[#666666] hover:text-black transition-colors">
{locale === "EN" ? "Home" : "Početna"}
</Link>
<span className="text-[#999999]">/</span>
<span className="text-[#1a1a1a]">{localized.name}</span>
</nav>
</div>
</div>
{/* Product Content */}
<div className="container py-12 lg:py-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
{/* Image Gallery - Left Side */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className="flex gap-4"
>
<div className="relative aspect-square bg-background-ice mb-4">
{images[selectedImage] && (
<Image
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
fill
className="object-cover"
priority
/>
)}
</div>
{/* Thumbnails - Vertical on Desktop, Hidden on Mobile */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto">
<div className="hidden md:flex flex-col gap-3 w-20 flex-shrink-0">
{images.map((image, index) => (
<button
key={image.id}
onClick={() => setSelectedImage(index)}
className={`relative w-20 h-20 flex-shrink-0 ${
selectedImage === index ? "ring-2 ring-foreground" : ""
className={`relative aspect-square w-full overflow-hidden border-2 transition-colors ${
selectedImage === index
? "border-black"
: "border-transparent hover:border-[#999999]"
}`}
>
<Image
@@ -84,103 +165,208 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
alt={image.alt || localized.name}
fill
className="object-cover"
sizes="80px"
/>
</button>
))}
</div>
)}
{/* Main Image */}
<div className="flex-1 relative aspect-square bg-[#f8f9fa] overflow-hidden">
{images[selectedImage] && (
<Image
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
fill
className="object-cover"
priority
sizes="(max-width: 1024px) 100vw, 50vw"
/>
)}
{/* Award Badge - Optional */}
<div className="absolute top-4 left-4">
<div className="bg-black text-white text-[10px] uppercase tracking-[0.1em] px-3 py-1.5">
{locale === "EN" ? "Bestseller" : "Najprodavanije"}
</div>
</div>
</div>
</motion.div>
{/* Product Info */}
{/* Product Info - Right Side */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8"
>
<h1 className="text-3xl md:text-4xl font-serif mb-4">
{/* Product Name */}
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name}
</h1>
<p className="text-2xl text-foreground-muted mb-6">
{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(localized.description).slice(0, 200)}...</p>
<p className="text-[#666666] leading-relaxed mb-6">
{shortDescription}
</p>
{/* Price & Rating */}
<div className="flex items-center justify-between mb-8">
<span className="text-3xl font-medium">
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
</span>
<StarRating rating={5} count={12} />
</div>
{/* 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 hover:bg-gray-50"
>
-
</button>
<span className="px-4 py-3 min-w-[3rem] text-center">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="px-4 py-3 hover:bg-gray-50"
>
+
</button>
{/* Divider */}
<div className="border-t border-[#e5e5e5] mb-8" />
{/* Size Selector */}
{product.variants && product.variants.length > 1 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{locale === "EN" ? "Size" : "Veličina"}
</span>
</div>
{/* Add to Cart Button */}
<div className="flex gap-3">
{product.variants.map((v) => (
<button
key={v.id}
className={`px-5 py-3 text-sm border-2 transition-colors ${
v.id === variant?.id
? "border-black bg-black text-white"
: "border-[#e5e5e5] hover:border-[#999999]"
}`}
>
{v.name}
</button>
))}
</div>
</div>
)}
{/* Quantity */}
<div className="flex items-center gap-4 mb-8">
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
{locale === "EN" ? "Qty" : "Kol"}
</span>
<div className="flex items-center border-2 border-[#1a1a1a]">
<button
onClick={handleAddToCart}
disabled={isAdding}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors disabled:opacity-50"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
disabled={quantity <= 1}
>
{isAdding
? (locale === "EN" ? "Adding..." : "Dodavanje...")
: (locale === "EN" ? "Add to Cart" : "Dodaj u korpu")
}
<Minus className="w-4 h-4" />
</button>
<span className="w-14 text-center text-base font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Add to Cart Button */}
{isAvailable ? (
<button
onClick={handleAddToCart}
disabled={isAdding}
className="w-full h-16 bg-black text-white text-base uppercase tracking-[0.15em] font-medium hover:bg-[#333333] active:bg-[#1a1a1a] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-8"
>
{isAdding
? (locale === "EN" ? "Adding..." : "Dodavanje...")
: (locale === "EN" ? "Add to Cart — Free Shipping" : "Dodaj u korpu — Besplatna dostava")
}
</button>
) : (
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
<div className="w-full h-16 bg-[#f8f9fa] text-[#666666] flex items-center justify-center text-base uppercase tracking-[0.15em] mb-8">
{locale === "EN" ? "Out of Stock" : "Nema na stanju"}
</div>
)}
{/* Free Shipping Note */}
<p className="text-center text-sm text-[#666666] mb-10">
{locale === "EN"
? "Free shipping on orders over 3,000 RSD"
: "Besplatna dostava za porudžbine preko 3.000 RSD"}
</p>
{/* Divider */}
<div className="border-t border-[#e5e5e5] mb-8" />
{/* Benefits */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{locale === "EN" ? "Benefits" : "Prednosti"}
</span>
</div>
<div className="flex flex-wrap gap-2">
{benefits.map((benefit, index) => (
<span
key={index}
className="px-4 py-2 text-sm border border-[#e5e5e5] text-[#666666]"
>
{benefit.trim()}
</span>
))}
</div>
</div>
{/* Expandable Sections */}
<div>
<ExpandableSection title={locale === "EN" ? "Description" : "Opis"}>
<div dangerouslySetInnerHTML={{ __html: localized.description }} />
</ExpandableSection>
<ExpandableSection title={locale === "EN" ? "How to Use" : "Kako koristiti"}>
<p>
{locale === "EN"
? "Apply a small amount to clean, damp hair or skin. Massage gently until absorbed. Use daily for best results."
: "Nanesite malu količinu na čistu, vlažnu kosu ili kožu. Nežno masirajte dok se ne upije. Koristite svakodnevno za najbolje rezultate."
}
</p>
</ExpandableSection>
<ExpandableSection title={locale === "EN" ? "Ingredients" : "Sastojci"}>
<p>
{locale === "EN"
? "100% Pure Natural Oil. No additives, preservatives, or artificial fragrances."
: "100% čisto prirodno ulje. Bez dodataka, konzervansa ili veštačkih mirisa."
}
</p>
</ExpandableSection>
</div>
{/* SKU */}
{variant?.sku && (
<p className="text-sm text-foreground-muted mb-4">
<p className="text-xs text-[#999999] mt-8">
SKU: {variant.sku}
</p>
)}
{/* Full Description */}
{localized.description && (
<div className="border-t border-border/30 pt-6">
<h3 className="font-serif text-lg mb-4">
{locale === "EN" ? "Description" : "Opis"}
</h3>
<div
className="prose max-w-none text-foreground-muted"
dangerouslySetInnerHTML={{ __html: localized.description }}
/>
</div>
)}
</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">
{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((relatedProduct, index) => (
{relatedProducts && relatedProducts.length > 0 && (
<section className="py-20 lg:py-28 bg-[#f8f9fa]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<span className="text-xs uppercase tracking-[0.2em] text-[#666666] mb-3 block">
{locale === "EN" ? "You May Also Like" : "Možda će vam se svideti"}
</span>
<h2 className="text-3xl md:text-4xl font-medium">
{locale === "EN" ? "Similar Products" : "Slični proizvodi"}
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{relatedProducts.filter(p => p && p.id).slice(0, 4).map((relatedProduct, index) => (
<ProductCard
key={relatedProduct.id}
product={relatedProduct}