create
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user