This commit is contained in:
amirhosein.ashourloo
2026-08-19 17:54:11 +03:30
parent b065fac757
commit 149b77f734
80 changed files with 3015 additions and 92 deletions
+84
View File
@@ -0,0 +1,84 @@
import type { StaticImageSlot } from "@/lib/types";
import { ShieldIcon, TagIcon, TruckIcon } from "./icons";
const points = [
{
icon: ShieldIcon,
title: "ضمانت اصالت کالا",
desc: "تمامی محصولات پیش از ارسال از نظر کیفیت و اصالت بررسی می‌شوند.",
},
{
icon: TruckIcon,
title: "ارسال سریع و رایگان",
desc: "ارسال به سراسر کشور با پیک و پست پیشتاز، رایگان برای خریدهای بالای سقف تعیین‌شده.",
},
{
icon: TagIcon,
title: "قیمت مناسب و عمده",
desc: "خرید مستقیم از تولیدی با قیمت رقابتی برای مشتریان خرد و عمده.",
},
];
export default function AboutUs({ slot }: { slot?: StaticImageSlot }) {
const img = slot?.images[0];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const src = img?.url ?? "/images/about/about.svg";
return (
<section id="about" className="boxed py-10 md:py-14">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-14 items-center">
<div className="overflow-hidden rounded-lg border border-line order-2 lg:order-1">
<img
src={src}
alt=""
style={aspectStyle}
className={`w-full object-cover ${aspectStyle ? "" : "aspect-[9/8]"}`}
/>
</div>
<div className="order-1 lg:order-2">
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
About Us
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
درباره ما
</h2>
<span className="mt-3 block h-[3px] w-14 bg-brand rounded-full" />
<p className="mt-5 text-sm md:text-[15px] leading-7 md:leading-8 text-body">
فروشگاه اینترنتی کودکانه، تولیدکننده و عرضه‌کننده پوشاک بچگانه با
جدیدترین طرح‌ها برای نوزادان، دختران و پسران است. تمام محصولات با
پارچه‌های نرم و مناسب پوست حساس کودکان تولید می‌شوند و پیش از
انتشار در سایت، از نظر کیفیت دوخت و اصالت بررسی می‌شوند تا خیالتان
برای انتخاب لباس فرزندتان راحت باشد.
</p>
<div className="mt-7 space-y-5">
{points.map((p) => (
<div key={p.title} className="flex items-start gap-3.5">
<span className="flex items-center justify-center size-11 rounded-lg bg-brand-light text-brand shrink-0">
<p.icon className="size-5" />
</span>
<div>
<h3 className="text-sm md:text-[15px] font-bold text-ink">
{p.title}
</h3>
<p className="mt-1 text-xs md:text-sm text-body leading-6">
{p.desc}
</p>
</div>
</div>
))}
</div>
<a
href="#contact"
className="mt-8 inline-flex items-center rounded-md bg-brand px-6 py-3 text-sm font-bold text-white hover:bg-brand-dark transition-colors"
>
اطلاعات بیشتر
</a>
</div>
</div>
</section>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import Link from "next/link";
import "swiper/css";
import type { ApiBlog } from "@/lib/types";
import SectionHeading from "./ui/SectionHeading";
export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) {
if (posts.length === 0) return null;
return (
<section className="bg-surface py-10 md:py-14">
<div className="boxed">
<SectionHeading kicker="Our Blog" title="جدیدترین مقالات وبلاگ" />
<Swiper
spaceBetween={20}
slidesPerView={1.15}
breakpoints={{
640: { slidesPerView: 2, spaceBetween: 20 },
1024: { slidesPerView: 3, spaceBetween: 24 },
}}
>
{posts.map((post) => {
const date = post.publishedAt || post.createdAt;
return (
<SwiperSlide key={post.id}>
<Link
href={`/blog/${post.slug}`}
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
>
<img
src={post.thumbnailUrl || post.image || "/images/blog/post-1.svg"}
alt=""
className="w-full aspect-[4/3] object-cover"
/>
<div className="flex flex-1 flex-col p-4">
{date && (
<span className="font-en text-[11px] tracking-wider text-gold-dark font-semibold">
{date.slice(0, 10)}
</span>
)}
<h3 className="mt-2 text-sm md:text-[15px] font-bold text-ink leading-6 line-clamp-2 group-hover:text-brand transition-colors">
{post.title || post.nameFa}
</h3>
{post.summary && (
<p className="mt-2 text-xs md:text-[13px] leading-6 text-body line-clamp-2">
{post.summary}
</p>
)}
</div>
</Link>
</SwiperSlide>
);
})}
</Swiper>
</div>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import Link from "next/link";
import type { CategoryNode } from "@/lib/types";
import SectionHeading from "./ui/SectionHeading";
const PLACEHOLDER_IMAGES = [
"/images/categories/girls.svg",
"/images/categories/boys.svg",
"/images/categories/baby.svg",
"/images/categories/party.svg",
"/images/categories/sets.svg",
"/images/categories/outerwear.svg",
];
export default function Categories({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const items = categoryColumns.flatMap((col) => col.children);
if (items.length === 0) return null;
return (
<section className="py-10 md:py-14">
<div className="boxed">
<SectionHeading kicker="Categories" title="دسته‌بندی محصولات" />
<div className="bg-gold rounded-2xl md:rounded-3xl py-8 px-4 md:px-8">
<Swiper
spaceBetween={10}
slidesPerView={4}
breakpoints={{
480: { slidesPerView: 5 },
640: { slidesPerView: 6 },
1024: { slidesPerView: 9, spaceBetween: 14 },
}}
>
{items.map((cat, i) => (
<SwiperSlide key={cat.id}>
<Link href={`/products?category=${cat.slug}`} className="group block text-center">
<div className="overflow-hidden rounded-lg bg-white">
<img
src={PLACEHOLDER_IMAGES[i % PLACEHOLDER_IMAGES.length]}
alt={cat.nameFa}
className="w-full aspect-square object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<span className="mt-2 block text-xs md:text-sm font-bold text-ink">
{cat.nameFa}
</span>
</Link>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import Link from "next/link";
import Logo from "./Logo";
import { headerNavLinks, siteInfo } from "@/data/site";
import type { CategoryNode } from "@/lib/types";
import {
InstagramIcon,
MailIcon,
PhoneIcon,
PinIcon,
TelegramIcon,
WhatsappIcon,
} from "./icons";
export default function Footer({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const categoryItems = categoryColumns.flatMap((col) => col.children).slice(0, 6);
return (
<footer id="contact" className="bg-brand text-white/70 mt-auto">
<div className="boxed py-12 md:py-16 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10">
<div>
<Logo dark />
<p className="mt-4 text-sm leading-7 text-white/55">
مرجع تخصصی پوشاک بچگانه؛ لباس‌های شاد و باکیفیت برای نوزادان،
دختران و پسران با ارسال به سراسر کشور.
</p>
<div className="mt-5 flex items-center gap-3">
<a
href="#"
aria-label="اینستاگرام"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<InstagramIcon className="size-4" />
</a>
<a
href="#"
aria-label="تلگرام"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<TelegramIcon className="size-4" />
</a>
<a
href="#"
aria-label="واتساپ"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<WhatsappIcon className="size-4" />
</a>
</div>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">دسترسی سریع</h3>
<ul className="space-y-2.5 text-sm">
{headerNavLinks.map((link) => (
<li key={link.title}>
<a href={link.href} className="hover:text-gold transition-colors">
{link.title}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">دسته‌بندی‌ها</h3>
<ul className="space-y-2.5 text-sm">
{categoryItems.map((cat) => (
<li key={cat.id}>
<Link href={`/products?category=${cat.slug}`} className="hover:text-gold transition-colors">
{cat.nameFa}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">ارتباط با ما</h3>
<ul className="space-y-3.5 text-sm">
<li className="flex items-start gap-2.5">
<PhoneIcon className="size-4 mt-0.5 text-gold shrink-0" />
<span dir="ltr">{siteInfo.phone}</span>
</li>
<li className="flex items-start gap-2.5">
<PinIcon className="size-4 mt-0.5 text-gold shrink-0" />
<span>{siteInfo.address}</span>
</li>
</ul>
</div>
</div>
<div className="border-t border-white/10">
<div className="boxed py-5 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-white/45">
<span>
© {new Date().getFullYear()} تمامی حقوق برای فروشگاه {siteInfo.name} محفوظ است.
</span>
<span className="font-en tracking-wider">KIDONE STORE</span>
</div>
</div>
<div className="bg-brand-dark">
<div dir="ltr" className="boxed py-3 flex items-center justify-center gap-2 text-white/55 text-xs">
<MailIcon className="size-4 shrink-0" />
<span>طراحی و توسعه توسط تیم تجارت الکترونیک مشکی</span>
</div>
</div>
</footer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { StaticImageSlot } from "@/lib/types";
export default function FullBanner({ slot }: { slot?: StaticImageSlot }) {
const img = slot?.images[0];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const src = img?.url ?? "/images/banners/full-banner.svg";
const href = img?.linkUrl ?? "#";
return (
<section className="boxed py-2 md:py-4">
<a href={href} className="block overflow-hidden rounded-lg border border-line">
<img
src={src}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/5] md:aspect-[16/4]"
}`}
/>
</a>
</section>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Logo from "./Logo";
import SearchBox from "./SearchBox";
import {
BagIcon,
ChevronDownIcon,
CloseIcon,
InstagramIcon,
MenuIcon,
TelegramIcon,
WhatsappIcon,
} from "./icons";
import { headerNavLinks } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import type { CategoryNode } from "@/lib/types";
const socialLinks = [
{ icon: InstagramIcon, label: "اینستاگرام" },
{ icon: TelegramIcon, label: "تلگرام" },
{ icon: WhatsappIcon, label: "واتساپ" },
];
export default function Header({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const [productsOpen, setProductsOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileCategoriesOpen, setMobileCategoriesOpen] = useState(false);
return (
<header className="sticky top-0 z-50 bg-surface pt-2 md:pt-3 pb-0">
<div className="boxed">
<div className="rounded-2xl shadow-sm border border-line/60">
{/* Row 1: logo / search / actions */}
<div className="bg-white rounded-2xl lg:rounded-t-2xl lg:rounded-b-none flex h-16 md:h-20 items-center gap-3 md:gap-6 px-3 md:px-6">
<button
type="button"
className="lg:hidden p-1.5 text-ink shrink-0"
aria-label="باز کردن منو"
onClick={() => setMobileOpen(true)}
>
<MenuIcon className="size-6" />
</button>
<Logo />
<div className="hidden md:flex flex-1 items-center">
<SearchBox />
</div>
<div className="flex items-center gap-2 md:gap-3 ms-auto md:ms-0">
<a
href="#"
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
aria-label="سبد خرید"
>
<BagIcon className="size-5" />
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
0
</span>
</a>
<a
href={CUSTOMER_PORTAL_URL}
className="hidden sm:flex items-center gap-1.5 rounded-full bg-brand text-white text-xs md:text-sm font-bold px-4 md:px-5 h-10 md:h-11 hover:bg-brand-dark transition-colors whitespace-nowrap"
>
ورود <span className="text-white/50">|</span> ثبت‌نام
</a>
</div>
</div>
{/* Row 2: nav + socials */}
<div className="hidden lg:flex bg-brand rounded-b-2xl items-center justify-between h-12 px-4 md:px-6">
<nav className="flex items-center h-full">
<div
className="relative h-full flex items-center"
onMouseEnter={() => setProductsOpen(true)}
onMouseLeave={() => setProductsOpen(false)}
>
<button
type="button"
className="flex h-full items-center gap-1.5 px-4 text-sm font-medium text-white/90 hover:text-gold transition-colors"
>
محصولات
<ChevronDownIcon className="size-4" />
</button>
{productsOpen && categoryColumns.length > 0 && (
<div className="absolute top-full start-0 min-w-[420px] bg-white border border-line shadow-lg p-6 flex flex-wrap gap-8 z-50">
{categoryColumns.map((col) => (
<div key={col.id} className="min-w-[180px]">
<Link
href={`/products?category=${col.slug}`}
className="block font-bold text-ink text-sm mb-2.5 hover:text-brand transition-colors"
>
{col.nameFa}
</Link>
<ul className="space-y-1.5">
{col.children.map((sub) => (
<li key={sub.id}>
<Link
href={`/products?category=${sub.slug}`}
className="text-[13px] text-body hover:text-gold-dark transition-colors"
>
{sub.nameFa}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
{headerNavLinks.map((link) => (
<a
key={link.title}
href={link.href}
className="flex h-full items-center px-4 text-sm font-medium text-white/90 hover:text-gold transition-colors"
>
{link.title}
</a>
))}
</nav>
<div className="flex items-center gap-2">
{socialLinks.map(({ icon: Icon, label }) => (
<a
key={label}
href="#"
aria-label={label}
className="flex items-center justify-center size-8 rounded-md bg-white/10 text-white hover:bg-white/20 transition-colors"
>
<Icon className="size-4" />
</a>
))}
</div>
</div>
</div>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-ink/50"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute inset-y-0 start-0 w-[82%] max-w-xs bg-white flex flex-col">
<div className="flex items-center justify-between h-16 px-4 border-b border-line">
<Logo />
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="بستن منو"
className="p-2 text-ink"
>
<CloseIcon className="size-5" />
</button>
</div>
<div className="p-4 overflow-y-auto">
<div className="mb-4">
<SearchBox placeholder="جستجو..." inputHeightClassName="h-10" />
</div>
<button
type="button"
className="flex w-full items-center justify-between py-3 text-sm font-bold text-ink border-b border-line"
onClick={() => setMobileCategoriesOpen((v) => !v)}
>
محصولات
<ChevronDownIcon
className={`size-4 transition-transform ${
mobileCategoriesOpen ? "rotate-180" : ""
}`}
/>
</button>
{mobileCategoriesOpen && (
<div className="py-2 ps-3 space-y-3 border-b border-line">
{categoryColumns.map((col) => (
<div key={col.id}>
<Link
href={`/products?category=${col.slug}`}
className="block text-sm font-bold text-ink"
onClick={() => setMobileOpen(false)}
>
{col.nameFa}
</Link>
<ul className="mt-1.5 ps-3 space-y-1.5">
{col.children.map((sub) => (
<li key={sub.id}>
<Link
href={`/products?category=${sub.slug}`}
className="text-sm text-body"
onClick={() => setMobileOpen(false)}
>
{sub.nameFa}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
{headerNavLinks.map((link) => (
<a
key={link.title}
href={link.href}
className="block py-3 text-sm font-bold text-ink border-b border-line"
>
{link.title}
</a>
))}
<a
href={CUSTOMER_PORTAL_URL}
className="mt-5 flex items-center justify-center rounded-full bg-brand text-white text-sm font-bold h-11 hover:bg-brand-dark transition-colors"
>
ورود / ثبت‌نام
</a>
<div className="mt-5 flex items-center justify-center gap-2">
{socialLinks.map(({ icon: Icon, label }) => (
<a
key={label}
href="#"
aria-label={label}
className="flex items-center justify-center size-9 rounded-md border border-line text-ink"
>
<Icon className="size-4" />
</a>
))}
</div>
</div>
</div>
</div>
)}
</header>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Pagination } from "swiper/modules";
import "swiper/css";
import "swiper/css/pagination";
import type { StaticImageSlot } from "@/lib/types";
const fallbackSlides = [
"/images/hero/slide-1.svg",
"/images/hero/slide-2.svg",
"/images/hero/slide-3.svg",
];
export default function HeroSlider({ slot }: { slot?: StaticImageSlot }) {
const slides = slot?.images ?? [];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
return (
<section className="boxed pt-4 md:pt-6">
<Swiper
modules={[Autoplay, Pagination]}
autoplay={{ delay: 4500, disableOnInteraction: false }}
pagination={{ clickable: true }}
loop
className="hero-swiper rounded-lg overflow-hidden"
>
{slides.length > 0
? slides.map((img) => (
<SwiperSlide key={img.id}>
{img.linkUrl ? (
<a href={img.linkUrl}>
<img
src={img.url}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/6] md:aspect-[32/9]"
}`}
/>
</a>
) : (
<img
src={img.url}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/6] md:aspect-[32/9]"
}`}
/>
)}
</SwiperSlide>
))
: fallbackSlides.map((src) => (
<SwiperSlide key={src}>
<img
src={src}
alt=""
className="w-full aspect-[16/6] md:aspect-[32/9] object-cover"
/>
</SwiperSlide>
))}
</Swiper>
</section>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { siteInfo } from "@/data/site";
export default function Logo({ dark = false }: { dark?: boolean }) {
return (
<Link href="/" className="flex items-center shrink-0" aria-label={siteInfo.name}>
<img
src="/images/logo.png"
alt={siteInfo.name}
className={`h-9 md:h-10 w-auto object-contain ${
dark ? "bg-white rounded-md px-2 py-1" : ""
}`}
/>
</Link>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import type { ApiProduct, StoreItem } from "@/lib/types";
import { BagIcon, CheckIcon, PhoneIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
const trustPoints = [
{ icon: ShieldIcon, title: "استفاده از بهترین متریال" },
{ icon: TruckIcon, title: "ارسال سریع به سراسر کشور" },
{ icon: PhoneIcon, title: "مشاوره و پشتیبانی آنلاین" },
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی کالا" },
];
export default function ProductBuyPanel({
product,
items,
}: {
product: ApiProduct;
items: StoreItem[];
}) {
const [selectedId, setSelectedId] = useState(items[0]?.id);
const [added, setAdded] = useState(false);
const selected = items.find((i) => i.id === selectedId) ?? items[0];
const title = product.nameFa || product.title;
const variationName = selected?.selections[0]?.variationName;
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
const stockQuantity = selected
? selected.stockQuantity
: items.reduce((sum, i) => sum + i.stockQuantity, 0);
let finalPrice: number | null = product.store.minPrice;
let oldPrice: number | null = null;
let discountPercent: number | null = null;
if (selected) {
const { price, discountedPrice } = selected;
if (discountedPrice != null && price != null && discountedPrice < price) {
finalPrice = discountedPrice;
oldPrice = price;
discountPercent = Math.round(((price - discountedPrice) / price) * 100);
} else {
finalPrice = discountedPrice ?? price ?? product.store.minPrice;
}
}
function handleAddToCart() {
setAdded(true);
setTimeout(() => setAdded(false), 1800);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8 md:gap-10">
{/* Info column */}
<div>
<h1 className="text-xl md:text-2xl font-extrabold text-ink">{title}</h1>
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
{product.summary && (
<div className="mt-5">
<p className="text-sm font-bold text-ink mb-1.5">توضیح کوتاه:</p>
<p className="text-sm text-body leading-7">{product.summary}</p>
</div>
)}
{items.length > 0 && (
<div className="mt-5">
<p className="text-sm font-bold text-ink mb-2.5">{variationName || "انتخاب گزینه"}</p>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const rawValue = item.selections[0]?.value;
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
disabled={item.stockQuantity <= 0}
title={item.label}
className={`flex items-center justify-center transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
colorHex
? `size-9 rounded-full border-2 ${
item.id === selectedId ? "border-brand" : "border-line"
}`
: `rounded-lg border px-4 h-10 text-sm font-bold ${
item.id === selectedId
? "border-brand bg-brand text-white"
: "border-line text-ink hover:border-brand hover:text-brand"
}`
}`}
>
{colorHex ? (
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
) : (
item.label
)}
</button>
);
})}
</div>
</div>
)}
<div className="mt-5 space-y-2 text-sm">
{items.length > 0 && (
<p className="text-body">
موجود در انبار:{" "}
<span className="font-bold text-ink">{stockQuantity.toLocaleString("en-US")} عدد</span>
</p>
)}
{product.categoryNameFa && (
<p className="text-body">
دسته‌بندی: <span className="font-bold text-ink">{product.categoryNameFa}</span>
</p>
)}
{product.tags.length > 0 && (
<p className="text-body">تگ‌ها: {product.tags.join("، ")}</p>
)}
</div>
</div>
{/* Purchase column */}
<div className="lg:sticky lg:top-24 space-y-4 h-fit">
<div className="rounded-2xl border border-line p-5 bg-white">
<div className="flex items-center justify-between pb-4 mb-4 border-b border-line">
<span
className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full ${
inStock ? "bg-brand-light text-brand" : "bg-ink/10 text-ink/50"
}`}
>
{inStock ? "آماده ارسال" : "ناموجود"}
</span>
<TruckIcon className="size-5 text-body" />
</div>
<div className="flex items-center justify-between text-sm text-body mb-1.5">
<span>قیمت:</span>
<div className="flex items-center gap-2">
{discountPercent != null && (
<span className="rounded-md bg-gold/20 text-gold-dark text-xs font-bold px-1.5 py-0.5">
%{discountPercent}
</span>
)}
{oldPrice != null && (
<span className="text-xs text-body/60 line-through">
{oldPrice.toLocaleString("en-US")}
</span>
)}
</div>
</div>
<div className="flex items-baseline justify-between mb-5">
<span className="text-xs text-body">تومان</span>
<span className="text-xl md:text-2xl font-extrabold text-ink">
{finalPrice != null ? finalPrice.toLocaleString("en-US") : "تماس بگیرید"}
</span>
</div>
<button
type="button"
onClick={handleAddToCart}
disabled={!inStock}
className={`w-full flex items-center justify-center gap-2 rounded-lg h-12 text-sm font-bold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? (
<>
<CheckIcon className="size-4.5" />
افزوده شد
</>
) : (
<>
<BagIcon className="size-4.5" />
افزودن به سبد خرید
</>
)}
</button>
</div>
<div className="rounded-2xl border border-dashed border-line p-5 grid grid-cols-2 gap-5">
{trustPoints.map((p) => (
<div key={p.title} className="flex flex-col items-center text-center gap-2">
<p.icon className="size-6 text-brand" />
<span className="text-xs text-body leading-5">{p.title}</span>
</div>
))}
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function ProductGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.length > 0 ? images : ["/images/products/product-1.svg"];
const [active, setActive] = useState(0);
function prev() {
setActive((i) => (i - 1 + gallery.length) % gallery.length);
}
function next() {
setActive((i) => (i + 1) % gallery.length);
}
return (
<div>
<div className="relative rounded-xl overflow-hidden border border-line bg-white">
<img src={gallery[active]} alt={title} className="w-full aspect-square object-cover" />
{gallery.length > 1 && (
<>
<button
type="button"
onClick={prev}
aria-label="تصویر قبلی"
className="absolute top-1/2 -translate-y-1/2 start-3 flex items-center justify-center size-9 rounded-full bg-white/90 border border-line text-ink hover:bg-white transition-colors"
>
<ChevronRightIcon className="size-4.5" />
</button>
<button
type="button"
onClick={next}
aria-label="تصویر بعدی"
className="absolute top-1/2 -translate-y-1/2 end-3 flex items-center justify-center size-9 rounded-full bg-white/90 border border-line text-ink hover:bg-white transition-colors"
>
<ChevronLeftIcon className="size-4.5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="mt-3 flex gap-2.5 overflow-x-auto">
{gallery.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setActive(i)}
className={`shrink-0 size-16 md:size-20 rounded-lg overflow-hidden border-2 transition-colors ${
i === active ? "border-brand" : "border-line"
}`}
>
<img src={src} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import type { EntityComment, TechnicalInfoValue } from "@/lib/types";
type Tab = "description" | "technical" | "reviews";
export default function ProductInfoTabs({
title,
descriptionHtml,
technicalValues,
comments,
}: {
title: string;
descriptionHtml?: string;
technicalValues: TechnicalInfoValue[];
comments: EntityComment[];
}) {
const [tab, setTab] = useState<Tab>("description");
const tabs: { key: Tab; label: string }[] = [
{ key: "description", label: "توضیحات" },
{ key: "technical", label: "مشخصات فنی" },
{ key: "reviews", label: `دیدگاه کاربران${comments.length > 0 ? ` (${comments.length})` : ""}` },
];
return (
<div className="mt-14 md:mt-20">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`rounded-t-lg px-5 h-11 text-sm font-bold transition-colors border-b-2 -mb-px ${
tab === t.key
? "border-brand text-brand"
: "border-transparent text-body hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="pt-8">
{tab === "description" && (
<div>
<h2 className="text-lg md:text-xl font-extrabold text-ink mb-4">معرفی {title}</h2>
{descriptionHtml ? (
<div
className="rich-text text-body leading-8"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "technical" && (
<div>
{technicalValues.length > 0 ? (
<dl className="divide-y divide-line rounded-xl border border-line overflow-hidden">
{technicalValues.map((v, i) => (
<div key={i} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
<dt className="text-body">{v.labelFa || v.label || v.key}</dt>
<dd className="font-bold text-ink">{v.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-body">مشخصات فنی این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "reviews" && (
<div>
{comments.length > 0 ? (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="rounded-xl border border-line p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-ink">{c.authorName || "کاربر"}</span>
{c.createdAt && (
<span className="font-en text-xs text-body/60">{c.createdAt.slice(0, 10)}</span>
)}
</div>
{c.text && <p className="mt-2 text-sm text-body leading-7">{c.text}</p>}
</li>
))}
</ul>
) : (
<p className="text-sm text-body">
هنوز دیدگاهی برای این محصول ثبت نشده است.
</p>
)}
</div>
)}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { getProducts } from "@/lib/api";
import type { ApiProduct } from "@/lib/types";
import { formatToman } from "@/lib/format";
import { SearchIcon } from "./icons";
const MIN_QUERY_LENGTH = 3;
const DEBOUNCE_MS = 350;
export default function SearchBox({
placeholder = "جستجوی محصولات...",
inputHeightClassName = "h-11",
}: {
placeholder?: string;
inputHeightClassName?: string;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<ApiProduct[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
function handleQueryChange(value: string) {
setQuery(value);
if (value.trim().length < MIN_QUERY_LENGTH) {
setResults([]);
setOpen(false);
setLoading(false);
} else {
setLoading(true);
}
}
useEffect(() => {
const term = query.trim();
if (term.length < MIN_QUERY_LENGTH) return;
const handle = setTimeout(() => {
getProducts({ name: term, pageSize: 6 })
.then((res) => {
setResults(res.items);
setOpen(true);
})
.catch(() => setResults([]))
.finally(() => setLoading(false));
}, DEBOUNCE_MS);
return () => clearTimeout(handle);
}, [query]);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const term = query.trim();
const showDropdown = open && term.length >= MIN_QUERY_LENGTH;
return (
<div ref={containerRef} className="relative w-full">
<div
className={`flex w-full items-center gap-2 rounded-full border border-line bg-surface px-4 ${inputHeightClassName}`}
>
<SearchIcon className="size-4.5 text-body shrink-0" />
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onFocus={() => term.length >= MIN_QUERY_LENGTH && setOpen(true)}
placeholder={placeholder}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-body/70"
/>
</div>
{showDropdown && (
<div className="absolute top-full mt-2 start-0 w-full bg-white border border-line rounded-xl shadow-lg z-50 max-h-96 overflow-y-auto">
{loading && <p className="p-4 text-sm text-body text-center">در حال جستجو...</p>}
{!loading && results.length === 0 && (
<p className="p-4 text-sm text-body text-center">نتیجه‌ای یافت نشد</p>
)}
{!loading &&
results.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
onClick={() => setOpen(false)}
className="flex items-center gap-3 p-3 hover:bg-surface transition-colors border-b border-line last:border-0"
>
<img
src={product.thumbnailUrl || product.image || "/images/products/product-1.svg"}
alt=""
className="size-12 rounded-md object-cover shrink-0 bg-surface"
/>
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm text-ink line-clamp-1">
{product.nameFa || product.title}
</p>
<p className="mt-0.5 text-xs font-bold text-brand">
{product.store?.minPrice != null
? formatToman(product.store.minPrice)
: "تماس بگیرید"}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Navigation } from "swiper/modules";
import "swiper/css";
import type { ApiProduct } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function SpecialSaleCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
return (
<section id="special-sale" className="boxed py-10 md:py-14">
<div className="flex items-center justify-between mb-6 md:mb-8">
<div>
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
Special Discount
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
تخفیف‌های ویژه
</h2>
</div>
<Link
href="/products"
className="flex items-center gap-1.5 rounded-full border border-brand text-brand text-xs md:text-sm font-bold px-4 md:px-5 h-9 md:h-10 hover:bg-brand hover:text-white transition-colors whitespace-nowrap"
>
مشاهده همه
<ChevronLeftIcon className="size-4 [stroke-width:2.2]" />
</Link>
</div>
<div className="relative bg-brand rounded-2xl md:rounded-3xl p-4 md:p-8">
<Swiper
modules={[Autoplay, Navigation]}
autoplay={{ delay: 3200, disableOnInteraction: false }}
navigation={{
prevEl: ".sale-prev",
nextEl: ".sale-next",
}}
loop={products.length > 5}
spaceBetween={14}
slidesPerView={2.15}
breakpoints={{
480: { slidesPerView: 2.4 },
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 5, spaceBetween: 20 },
}}
>
<SwiperSlide>
<img
src="/images/special-sale/banner.svg"
alt=""
className="w-full h-full aspect-[4/5] object-cover rounded-lg"
/>
</SwiperSlide>
{products.map((product) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
<button
type="button"
aria-label="اسلاید قبلی"
className="sale-prev hidden sm:flex absolute top-1/2 -translate-y-1/2 start-0 z-10 translate-x-1/2 items-center justify-center size-10 md:size-11 rounded-full bg-white text-brand shadow-md hover:bg-gold hover:text-ink transition-colors"
>
<ChevronRightIcon className="size-5" />
</button>
<button
type="button"
aria-label="اسلاید بعدی"
className="sale-next hidden sm:flex absolute top-1/2 -translate-y-1/2 end-0 z-10 -translate-x-1/2 items-center justify-center size-10 md:size-11 rounded-full bg-white text-brand shadow-md hover:bg-gold hover:text-ink transition-colors"
>
<ChevronLeftIcon className="size-5" />
</button>
</div>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import type { ApiProduct } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import SectionHeading from "./ui/SectionHeading";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function TopSellingCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
return (
<section className="boxed py-10 md:py-14">
<div className="flex items-end justify-between">
<SectionHeading kicker="Best Sellers" title="پرفروش‌ترین‌ها" align="start" />
<div className="hidden sm:flex items-center gap-2 mb-10">
<button
type="button"
className="top-selling-prev flex items-center justify-center size-9 rounded-full border border-line text-ink hover:bg-brand hover:border-brand hover:text-white transition-colors"
aria-label="قبلی"
>
<ChevronRightIcon className="size-4" />
</button>
<button
type="button"
className="top-selling-next flex items-center justify-center size-9 rounded-full border border-line text-ink hover:bg-brand hover:border-brand hover:text-white transition-colors"
aria-label="بعدی"
>
<ChevronLeftIcon className="size-4" />
</button>
</div>
</div>
<Swiper
modules={[Navigation]}
navigation={{
prevEl: ".top-selling-prev",
nextEl: ".top-selling-next",
}}
spaceBetween={16}
slidesPerView={2.2}
breakpoints={{
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 4 },
1280: { slidesPerView: 5 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { StaticImageSlot } from "@/lib/types";
const fallbackBanners = [
{ image: "/images/banners/banner-1.svg", href: "#" },
{ image: "/images/banners/banner-2.svg", href: "#" },
];
export default function TwoBanners({ slot }: { slot?: StaticImageSlot }) {
const images = slot?.images ?? [];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const banners =
images.length > 0
? images.map((img) => ({ image: img.url, href: img.linkUrl || undefined }))
: fallbackBanners;
return (
<section className="boxed py-10 md:py-14">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{banners.map((b, i) => {
const Wrapper = b.href ? "a" : "div";
return (
<Wrapper
key={i}
{...(b.href ? { href: b.href } : {})}
className="group block overflow-hidden rounded-lg border border-line"
>
<img
src={b.image}
alt=""
style={aspectStyle}
className={`w-full object-cover transition-transform duration-300 group-hover:scale-105 ${
aspectStyle ? "" : "aspect-[16/8]"
}`}
/>
</Wrapper>
);
})}
</div>
</section>
);
}
+173
View File
@@ -0,0 +1,173 @@
type IconProps = { className?: string };
const base = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function SearchIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
}
export function BagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 8h12l-1 12H7L6 8Z" />
<path d="M9 8V6a3 3 0 0 1 6 0v2" />
</svg>
);
}
export function ChevronDownIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function ChevronLeftIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
export function ChevronRightIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M9 18l6-6-6-6" />
</svg>
);
}
export function MenuIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
}
export function CloseIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
export function PhoneIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M5 4h3l2 5-2.5 1.5a11 11 0 0 0 5 5L14 13l5 2v3a2 2 0 0 1-2 2C10.5 20 4 13.5 4 6a2 2 0 0 1 1-2Z" />
</svg>
);
}
export function TruckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M3 7h11v9H3z" />
<path d="M14 10h4l3 3v3h-7z" />
<circle cx="7" cy="18" r="1.6" />
<circle cx="17.5" cy="18" r="1.6" />
</svg>
);
}
export function ShieldIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3Z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
export function TagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M11 3h6a2 2 0 0 1 2 2v6l-9 9-8-8 9-9Z" />
<circle cx="15.5" cy="8.5" r="1.2" />
</svg>
);
}
export function InstagramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17" cy="7" r="0.6" fill="currentColor" stroke="none" />
</svg>
);
}
export function TelegramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.446 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.334-.373-.121l-6.869 4.326-2.96-.924c-.643-.204-.658-.643.136-.953l11.57-4.461c.538-.196 1.006.128.828.941z" />
</svg>
);
}
export function WhatsappIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none">
<path d="M12.04 2.003c-5.5 0-9.96 4.46-9.96 9.96 0 1.76.46 3.48 1.34 4.99L2 22l5.2-1.36a9.94 9.94 0 0 0 4.84 1.24c5.5 0 9.96-4.46 9.96-9.96 0-2.66-1.04-5.16-2.92-7.04a9.9 9.9 0 0 0-7.04-2.92zm0 1.8c2.15 0 4.17.84 5.7 2.36a8.03 8.03 0 0 1 2.36 5.7c0 4.44-3.62 8.06-8.07 8.06a8.05 8.05 0 0 1-4.1-1.12l-.29-.17-3.06.8.82-2.99-.19-.31a7.98 7.98 0 0 1-1.23-4.28c0-4.45 3.61-8.06 8.06-8.06zm-4.4 4.6c-.15 0-.39.05-.6.29-.2.23-.78.76-.78 1.85s.8 2.15.91 2.3c.11.15 1.56 2.47 3.83 3.37.53.22.95.35 1.28.44.54.15 1.03.13 1.42.08.43-.07 1.34-.55 1.53-1.08.19-.53.19-.99.13-1.08-.06-.1-.2-.15-.43-.27-.22-.11-1.34-.66-1.55-.74-.21-.08-.36-.11-.51.11-.15.22-.58.74-.71.89-.13.15-.26.16-.48.05-.22-.11-.95-.35-1.81-1.12-.67-.6-1.12-1.33-1.25-1.55-.13-.22-.01-.34.1-.45.1-.1.22-.26.33-.39.11-.13.15-.22.22-.37.07-.15.04-.28-.02-.39-.05-.11-.51-1.27-.71-1.74-.19-.45-.38-.39-.51-.4h-.44z" />
</svg>
);
}
export function PinIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M12 21s7-6.5 7-12a7 7 0 1 0-14 0c0 5.5 7 12 7 12Z" />
<circle cx="12" cy="9" r="2.3" />
</svg>
);
}
export function RotateIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 12a8 8 0 0 1 14-5.2M20 12a8 8 0 0 1-14 5.2" />
<path d="M18 3v4h-4M6 21v-4h4" />
</svg>
);
}
export function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
}
export function CommentIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 5h16v11H8l-4 4V5Z" />
</svg>
);
}
export function MailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="M4 6.5l8 6 8-6" />
</svg>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from "next/link";
import { ChevronLeftIcon, ChevronRightIcon } from "../icons";
export default function Pagination({
page,
totalPages,
buildHref,
}: {
page: number;
totalPages: number;
buildHref: (page: number) => string;
}) {
if (totalPages <= 1) return null;
const pages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(
(p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1
);
return (
<nav className="mt-10 flex items-center justify-center gap-1.5" aria-label="صفحه‌بندی">
<Link
href={buildHref(Math.max(1, page - 1))}
aria-disabled={page === 1}
className={`flex items-center justify-center size-9 rounded-full border border-line text-ink transition-colors ${
page === 1 ? "pointer-events-none opacity-40" : "hover:bg-brand hover:border-brand hover:text-white"
}`}
>
<ChevronRightIcon className="size-4" />
</Link>
{pages.map((p, i) => {
const prev = pages[i - 1];
const gap = prev !== undefined && p - prev > 1;
return (
<span key={p} className="flex items-center gap-1.5">
{gap && <span className="px-1 text-body">...</span>}
<Link
href={buildHref(p)}
className={`flex items-center justify-center size-9 rounded-full text-sm font-bold transition-colors ${
p === page
? "bg-brand text-white"
: "border border-line text-ink hover:bg-brand hover:border-brand hover:text-white"
}`}
>
{p}
</Link>
</span>
);
})}
<Link
href={buildHref(Math.min(totalPages, page + 1))}
aria-disabled={page === totalPages}
className={`flex items-center justify-center size-9 rounded-full border border-line text-ink transition-colors ${
page === totalPages
? "pointer-events-none opacity-40"
: "hover:bg-brand hover:border-brand hover:text-white"
}`}
>
<ChevronLeftIcon className="size-4" />
</Link>
</nav>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import type { ApiProduct } from "@/lib/types";
import { formatToman } from "@/lib/format";
export default function ProductCard({ product }: { product: ApiProduct }) {
const title = product.nameFa || product.title;
const image =
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
const store = product.store;
return (
<Link
href={`/products/${product.slug}`}
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
>
<div className="relative">
<img src={image} alt={title} className="w-full aspect-[4/5] object-cover" />
{store && !store.inStock && (
<span className="absolute inset-0 flex items-center justify-center bg-white/70">
<span className="rounded-full bg-ink/85 text-white text-xs font-medium px-3 py-1">
ناموجود
</span>
</span>
)}
</div>
<div className="flex flex-1 flex-col p-3.5">
<h3 className="text-[13px] md:text-sm text-ink line-clamp-2 min-h-[2.5em]">{title}</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm md:text-[15px] text-brand">
{store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"}
</span>
{store?.maxPrice != null && store.maxPrice !== store.minPrice && (
<span className="text-xs text-body/60">تا {formatToman(store.maxPrice)}</span>
)}
</div>
</div>
</Link>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function SectionHeading({
kicker,
title,
align = "center",
}: {
kicker: string;
title: string;
align?: "center" | "start";
}) {
return (
<div className={`mb-8 md:mb-10 ${align === "center" ? "text-center" : "text-start"}`}>
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
{kicker}
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">{title}</h2>
<span
className={`mt-3 block h-[3px] w-14 bg-brand rounded-full ${
align === "center" ? "mx-auto" : ""
}`}
/>
</div>
);
}