Build the Salar Store storefront and connect products, blogs, and home specials to the Meshkee API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-16 01:36:29 +03:30
co-authored by Cursor
parent 2783305f07
commit 7d26eaa838
113 changed files with 3269 additions and 96 deletions
+29
View File
@@ -0,0 +1,29 @@
import Image from "next/image";
import Link from "next/link";
import { SITE } from "@/lib/site";
export function AboutUs() {
return (
<section id="about" className="container-site mt-14">
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
<div>
<p className="font-carter text-brand tracking-[0.2em] text-sm mb-2">SALAR STORE</p>
<h2 className="section-title mb-4">درباره ما</h2>
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
<Link href="/aboutus" className="mt-5 inline-block text-sm text-brand">
مشاهده بیشتر
</Link>
</div>
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
</div>
</section>
);
}
+48
View File
@@ -0,0 +1,48 @@
import Image from "next/image";
import Link from "next/link";
import { banners3, banners4 } from "@/lib/data";
import { Media } from "./Media";
export function BannerGrid4() {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-2 gap-3 md:gap-4">
{banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
}}
/>
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
}
export function BannerRow3() {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt="" className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import { getLatestPosts } from "@/lib/mappers";
import { Carousel } from "./Carousel";
import { Media } from "./Media";
export async function BlogCarousel() {
const posts = await getLatestPosts();
if (!posts.length) return null;
return (
<section className="container-site mt-14">
<div className="flex items-end justify-between mb-6">
<h2 className="section-title">آخرین مقالات</h2>
<Link href="/blog" className="text-sm text-brand">
همه مقالات
</Link>
</div>
<Carousel ariaLabel="مقالات">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="snap-start shrink-0 w-[240px] sm:w-[280px] rounded-2xl bg-white border border-black/5 overflow-hidden hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="280px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
{post.abstract ? (
<p className="mt-1 text-xs leading-6 text-zinc-500 line-clamp-2">{post.abstract}</p>
) : null}
</div>
</Link>
))}
</Carousel>
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Image from "next/image";
import { brands } from "@/lib/data";
import { Carousel } from "./Carousel";
export function Brands() {
return (
<section className="container-site mt-12">
<h2 className="section-title mb-6">برندها</h2>
<Carousel ariaLabel="برندها">
{brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image src={brand.image} alt={brand.name} width={180} height={80} className="max-h-[72px] w-auto object-contain" />
</div>
))}
</Carousel>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useRef, type ReactNode } from "react";
import { IconArrow } from "./Icons";
type Props = {
children: ReactNode;
className?: string;
ariaLabel?: string;
flush?: boolean;
tight?: boolean;
};
export function Carousel({ children, className = "", ariaLabel, flush = false, tight = false }: Props) {
const scroller = useRef<HTMLDivElement>(null);
const move = (direction: number) => {
const el = scroller.current;
if (!el) return;
const delta = Math.max(el.clientWidth * 0.55, 240) * direction;
el.scrollBy({ left: delta, behavior: "smooth" });
};
return (
<div className={`relative ${className}`}>
<div
ref={scroller}
aria-label={ariaLabel}
className={`flex overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${
flush ? "divide-x divide-zinc-200" : tight ? "gap-2" : "gap-4 pb-2"
}`}
>
{children}
</div>
<button
type="button"
aria-label="قبلی"
onClick={() => move(1)}
className="absolute top-1/2 right-2 z-10 -translate-y-1/2 hidden md:grid place-items-center size-8 rounded-md bg-white text-ink shadow-sm border border-black/10 hover:bg-brand hover:text-white"
>
<IconArrow dir="right" className="w-4 h-4" />
</button>
<button
type="button"
aria-label="بعدی"
onClick={() => move(-1)}
className="absolute top-1/2 left-2 z-10 -translate-y-1/2 hidden md:grid place-items-center size-8 rounded-md bg-white text-ink shadow-sm border border-black/10 hover:bg-brand hover:text-white"
>
<IconArrow dir="left" className="w-4 h-4" />
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import Image from "next/image";
import { categories } from "@/lib/data";
import { DrawFrame } from "./DrawFrame";
export function Categories() {
return (
<section className="container-site mt-12">
<div className="flex items-end justify-between mb-6">
<h2 className="section-title">دسته‌بندی‌ها</h2>
<Link href="/products" className="text-sm text-brand">
همه محصولات
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{categories.map((cat) => (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span dir="ltr" className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase">
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
))}
</div>
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
export function ContactForm() {
const [sent, setSent] = useState(false);
return (
<form
className="rounded-2xl bg-zinc-50 p-5 md:p-8"
onSubmit={(event) => {
event.preventDefault();
setSent(true);
}}
>
<h2 className="text-xl md:text-2xl font-extrabold mb-1">نظرتون با ما به اشتراک بزارید</h2>
<p dir="ltr" className="mb-6 font-carter text-[10px] tracking-[0.35em] text-zinc-400 uppercase">
SEND MESSAGE
</p>
{sent ? (
<p className="rounded-xl bg-white px-4 py-6 text-sm leading-7 text-zinc-600">
پیام شما ثبت شد. به‌زودی از طریق تماس یا پیام پاسخ می‌دهیم.
</p>
) : (
<div className="grid gap-3">
<input
required
name="name"
type="text"
placeholder="نام و نام خانوادگی"
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
/>
<input
required
name="email"
type="email"
placeholder="ایمیل"
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
/>
<input
required
name="cell_number"
type="tel"
placeholder="شماره همراه"
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
dir="ltr"
/>
<input
required
name="title"
type="text"
placeholder="موضوع"
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
/>
<textarea
required
name="message"
placeholder="پیام"
rows={6}
className="w-full resize-y rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
/>
<button
type="submit"
className="mt-2 inline-flex items-center justify-center rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white hover:bg-[#e01e1e]"
>
ارسال پیام
</button>
</div>
)}
</form>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function DrawFrame() {
return <span className="draw-frame" aria-hidden="true" />;
}
+120
View File
@@ -0,0 +1,120 @@
import Image from "next/image";
import Link from "next/link";
import { features, footerLinks } from "@/lib/data";
import { SITE } from "@/lib/site";
import { IconInstagram, IconPhone, IconPin, IconTelegram, IconWhatsapp } from "./Icons";
function MeshkeeCredit() {
return (
<a href={SITE.meshkeeUrl} target="_blank" rel="noopener noreferrer" dir="ltr" className="meshkee-credit">
<span className="meshkee-credit__frame" aria-hidden="true">
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<Image
src="/images/logo/meshkee-white.png"
alt="Meshkee"
width={42}
height={42}
className="meshkee-credit__logo"
/>
<span className="meshkee-credit__text">
<span className="meshkee-credit__label">Designed And Developed By</span>
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
</span>
</a>
);
}
export function Footer() {
return (
<footer className="mt-16 bg-ink text-white">
<div className="bg-white">
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
{features.map((item) => (
<div key={item.title} className="flex items-center gap-3">
<Image src={item.image} alt="" width={48} height={48} className="w-12 h-12 object-contain" />
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
</div>
))}
</div>
</div>
<div className="container-site py-12 grid gap-10 md:grid-cols-2 lg:grid-cols-4">
<div className="lg:col-span-1">
<div className="mb-4 inline-flex rounded-xl bg-white px-4 py-3">
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={160} height={44} className="h-11 w-auto" />
</div>
<p className="text-sm leading-8 text-white/70 text-justify">{SITE.aboutShort}</p>
<div className="flex items-center gap-3 mt-5">
<a href={SITE.telegram} aria-label="تلگرام" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
<IconTelegram />
</a>
<a href={SITE.instagram} aria-label="اینستاگرام" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
<IconInstagram />
</a>
<a href={SITE.whatsapp} aria-label="واتساپ" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
<IconWhatsapp />
</a>
</div>
</div>
<div>
<h4 className="mb-4 text-lg font-bold">پیوندها</h4>
<ul className="space-y-2.5 text-sm text-white/70">
{footerLinks.pages.map((link) => (
<li key={link.href}>
<Link href={link.href} className="hover:text-white">
{link.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="mb-4 text-lg font-bold">راهنمایی و پشتیبانی</h4>
<ul className="space-y-2.5 text-sm text-white/70">
{footerLinks.support.map((link) => (
<li key={link.href}>
<Link href={link.href} className="hover:text-white">
{link.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="mb-4 text-lg font-bold">راه‌های ارتباطی</h4>
<a href={`tel:${SITE.phone}`} className="flex items-center gap-3 text-sm">
<span className="grid place-items-center w-10 h-10 rounded-full bg-white/10 text-brand shrink-0">
<IconPhone className="w-4 h-4" />
</span>
<span>
<span className="block text-white/50 text-xs mb-0.5">تلفن تماس</span>
<span className="font-oswald tracking-wide text-base" dir="ltr">
{SITE.phoneDisplay}
</span>
</span>
</a>
<p className="mt-4 flex items-start gap-3 text-sm text-white/70">
<span className="grid place-items-center w-10 h-10 rounded-full bg-white/10 text-brand shrink-0">
<IconPin className="w-4 h-4" />
</span>
{SITE.address}
</p>
</div>
</div>
<div className="bg-[#1d1d1d]">
<div className="container-site footer-bottom-bar py-4">
<p className="footer-bottom-site">{SITE.nameFa}</p>
<MeshkeeCredit />
</div>
</div>
</footer>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { cartCount } from "@/lib/cart";
import { parentCategories } from "@/lib/data";
import { SITE } from "@/lib/site";
import { IconCart, IconChevron, IconClose, IconMenu, IconUser } from "./Icons";
import { SearchBox } from "./SearchBox";
const nav = [
{ name: "فروش ویژه", href: "#special-sale" },
{ name: "درباره ما", href: "/aboutus" },
{ name: "سوالات متداول", href: "/frequently-asked-questions" },
{ name: "تماس با ما", href: "/contactus" },
];
export function Header() {
const [open, setOpen] = useState(false);
const [mega, setMega] = useState(false);
const [count, setCount] = useState(0);
useEffect(() => {
const sync = () => setCount(cartCount());
sync();
window.addEventListener("cart-updated", sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener("cart-updated", sync);
window.removeEventListener("storage", sync);
};
}, []);
return (
<header className="sticky top-0 z-50 bg-white border-b border-black/5">
<div className="container-site flex h-[76px] items-center gap-4">
<button
type="button"
className="lg:hidden grid place-items-center w-10 h-10 text-ink"
aria-label="منو"
onClick={() => setOpen(true)}
>
<IconMenu />
</button>
<Link href="/" className="shrink-0">
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={150} height={40} className="h-10 w-auto" priority />
</Link>
<nav className="hidden lg:flex items-center gap-1 mr-4 text-sm">
<div
className="relative"
onMouseEnter={() => setMega(true)}
onMouseLeave={() => setMega(false)}
>
<Link
href="/products"
className="flex items-center gap-1 px-3 py-2 rounded-lg font-medium hover:text-brand"
>
محصولات
<IconChevron className="w-3.5 h-3.5 -rotate-90" />
</Link>
{mega && (
<div className="absolute top-full right-0 w-[760px] rounded-2xl bg-white border border-black/5 shadow-xl p-5 grid grid-cols-4 gap-5">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
{cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 8).map((child) => (
<li key={child.slug}>
<Link
href={`/products/${child.slug}`}
className="block text-[13px] text-zinc-600 hover:text-ink"
>
{child.name}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
{nav.map((item) => (
<Link key={item.href} href={item.href} className="px-3 py-2 rounded-lg hover:text-brand">
{item.name}
</Link>
))}
</nav>
<SearchBox className="hidden md:block ms-4 max-w-sm flex-1 rounded-full bg-zinc-100 px-3" />
<div className="flex items-center gap-2 ms-auto">
<a
href={SITE.customerUrl}
className="flex items-center gap-2 rounded-full px-3 py-2 text-sm hover:bg-zinc-100"
>
<IconUser />
<span className="hidden sm:inline">ورود / ثبت نام</span>
</a>
<Link href="/cart" className="relative grid place-items-center w-10 h-10 rounded-full hover:bg-zinc-100">
<IconCart className="w-6 h-6 text-brand" />
<span className="absolute top-1 left-1 grid place-items-center w-4 h-4 rounded-full bg-brand text-[10px] text-white">
{count.toLocaleString("fa-IR")}
</span>
</Link>
</div>
</div>
{open && (
<div className="fixed inset-0 z-50 lg:hidden">
<button className="absolute inset-0 bg-black/40" aria-label="بستن" onClick={() => setOpen(false)} />
<aside className="absolute inset-y-0 right-0 w-[86%] max-w-sm bg-white p-5 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={130} height={36} className="h-9 w-auto" />
<button type="button" aria-label="بستن منو" onClick={() => setOpen(false)}>
<IconClose />
</button>
</div>
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
<div className="space-y-4 mb-6">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
{cat.name}
</Link>
<ul className="mt-2 grid grid-cols-2 gap-1">
{cat.children.slice(0, 6).map((child) => (
<li key={child.slug}>
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
{child.name}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<div className="flex flex-col gap-2 border-t border-black/5 pt-4">
{nav.map((item) => (
<Link key={item.href} href={item.href} className="py-2" onClick={() => setOpen(false)}>
{item.name}
</Link>
))}
<a href={SITE.customerUrl} className="py-2">
ورود / ثبت نام
</a>
</div>
</aside>
</div>
)}
</header>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { heroSides, heroSlides } from "@/lib/data";
const overlayStyle = {
background:
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
};
export function HeroSlider() {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
const id = window.setInterval(() => {
setIndex((current) => (current + 1) % heroSlides.length);
}, 5000);
return () => window.clearInterval(id);
}, [paused]);
return (
<section className="container-site pt-5">
<div
className="relative overflow-hidden rounded-2xl aspect-[21/8] min-h-[240px] md:min-h-[360px]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt=""
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
/>
</div>
))}
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase">
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{heroSlides.map((slide, i) => (
<button
key={slide.title}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image src={side.image} alt="" fill sizes="600px" className="object-cover object-[28%_center]" />
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
}
+108
View File
@@ -0,0 +1,108 @@
export function IconSearch({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" strokeLinecap="round" />
</svg>
);
}
export function IconUser({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="3.5" />
<path d="M5 19c1.5-3.5 4-5 7-5s5.5 1.5 7 5" strokeLinecap="round" />
</svg>
);
}
export function IconCart({ className = "w-6 h-6" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
d="M6.2 7h11.6l.86 11.2A1.8 1.8 0 0 1 16.87 20H7.13a1.8 1.8 0 0 1-1.79-1.8L6.2 7Z"
strokeLinejoin="round"
/>
<path d="M9 7V5.6A3 3 0 0 1 12 2.6 3 3 0 0 1 15 5.6V7" strokeLinecap="round" />
</svg>
);
}
export function IconChevron({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconMenu({ className = "w-6 h-6" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 7h16M4 12h16M4 17h16" strokeLinecap="round" />
</svg>
);
}
export function IconClose({ className = "w-6 h-6" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
);
}
export function IconPhone({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 3h4l1 5-3 2a12 12 0 0 0 5 5l2-3 5 1v4c0 1-1 2-2 2C10 19 5 14 5 5c0-1 1-2 2-2z" strokeLinejoin="round" />
</svg>
);
}
export function IconPin({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 21s7-5.4 7-11a7 7 0 1 0-14 0c0 5.6 7 11 7 11z" />
<circle cx="12" cy="10" r="2.2" />
</svg>
);
}
export function IconArrow({ dir = "left", className = "w-5 h-5" }: { dir?: "left" | "right"; className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{dir === "left" ? (
<path d="M15 5l-7 7 7 7" strokeLinecap="round" strokeLinejoin="round" />
) : (
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
);
}
export function IconTelegram({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M21.5 4.3 18.2 20c-.25 1.1-.9 1.35-1.82.84l-5.04-3.7-2.43 2.34c-.27.27-.5.5-.1.5l.9-4.1 10.2-9.2c.44-.4-.1-.62-.68-.23L6.4 13.2 2.45 12c-1.07-.33-1.08-1.07.23-1.62l16.9-6.52c.9-.34 1.68.2 1.92 1.44z" />
</svg>
);
}
export function IconInstagram({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="4" width="16" height="16" rx="5" />
<circle cx="12" cy="12" r="3.5" />
<circle cx="17.3" cy="6.7" r="0.8" fill="currentColor" stroke="none" />
</svg>
);
}
export function IconWhatsapp({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.04 3.5A8.45 8.45 0 0 0 4.4 15.7L3.5 20.5l4.92-.9A8.45 8.45 0 1 0 12.04 3.5zm4.9 12.05c-.2.57-1.18 1.05-1.64 1.12-.42.06-.95.09-1.54-.1-.35-.11-.8-.26-1.38-.51-2.43-1.05-4-3.5-4.13-3.66-.12-.16-1.02-1.36-1.02-2.6 0-1.23.65-1.84.88-2.09.23-.25.5-.31.67-.31h.48c.15 0 .36-.06.56.43.2.5.68 1.73.74 1.86.06.12.1.27.02.44-.08.16-.12.27-.24.41-.12.15-.25.33-.36.44-.12.12-.24.25-.1.49.14.24.62 1.02 1.33 1.65.92.82 1.69 1.07 1.93 1.19.24.12.38.1.52-.06.14-.16.6-.7.76-.94.16-.24.32-.2.54-.12.22.08 1.4.66 1.64.78.24.12.4.18.46.28.06.1.06.58-.14 1.15z" />
</svg>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Image, { type StaticImageData } from "next/image";
type Props = {
src: string | StaticImageData;
alt?: string;
className?: string;
sizes?: string;
priority?: boolean;
};
export function Media({ src, alt = "", className = "", sizes, priority }: Props) {
const remote = typeof src === "string" && /^https?:\/\//.test(src);
return (
<div className={`relative overflow-hidden bg-zinc-100 ${className}`}>
<Image
src={src}
alt={alt}
fill
sizes={sizes ?? "100vw"}
className="object-cover"
priority={priority}
unoptimized={remote}
/>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
type Crumb = {
name: string;
href?: string;
};
type Props = {
title: string;
english: string;
crumbs?: Crumb[];
};
export function PageHero({ title, english, crumbs }: Props) {
const trail = crumbs ?? [{ name: title }];
return (
<div className="page-hero border-b border-black/5">
<div className="container-site flex items-end justify-between gap-4 py-4 md:py-5">
<div className="min-w-0">
<p className="text-xs text-zinc-400 mb-1">
<Link href="/" className="hover:text-brand">
خانه
</Link>
{trail.map((crumb) => (
<span key={crumb.name}>
<span className="mx-1.5">/</span>
{crumb.href ? (
<Link href={crumb.href} className="hover:text-brand">
{crumb.name}
</Link>
) : (
<span>{crumb.name}</span>
)}
</span>
))}
</p>
<h1 className="text-xl md:text-2xl font-extrabold">{title}</h1>
</div>
<p dir="ltr" className="shrink-0 pb-0.5 font-carter text-[10px] tracking-[0.4em] text-zinc-400 uppercase">
{english}
</p>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
type Props = {
page: number;
pageSize: number;
total: number;
makeHref: (page: number) => string;
};
export function Pagination({ page, pageSize, total, makeHref }: Props) {
const pages = Math.max(1, Math.ceil(total / pageSize));
if (pages <= 1) return null;
const prev = page > 1 ? page - 1 : null;
const next = page < pages ? page + 1 : null;
return (
<div className="mt-10 flex items-center justify-center gap-3 text-sm">
{prev ? (
<Link href={makeHref(prev)} className="rounded-lg border border-black/10 px-4 py-2 hover:border-brand hover:text-brand">
قبلی
</Link>
) : (
<span className="rounded-lg border border-black/5 px-4 py-2 text-zinc-300">قبلی</span>
)}
<span className="text-zinc-500">
صفحه {page} از {pages}
</span>
{next ? (
<Link href={makeHref(next)} className="rounded-lg border border-black/10 px-4 py-2 hover:border-brand hover:text-brand">
بعدی
</Link>
) : (
<span className="rounded-lg border border-black/5 px-4 py-2 text-zinc-300">بعدی</span>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { discountPercent, formatPrice } from "@/lib/format";
import type { Product } from "@/lib/data";
import { Media } from "./Media";
export function ProductCard({ product }: { product: Product }) {
const off = discountPercent(product.price, product.oldPrice);
const unavailable = product.stock === 0 || product.price === 0;
return (
<Link href={product.slug ? `/products/${product.slug}` : "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
{off > 0 && (
<span className="absolute top-2 left-2 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
{off}%
</span>
)}
<div className="relative">
<Media src={product.image} alt="" className="aspect-square rounded-lg" sizes="220px" />
</div>
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
<div className="mt-auto px-1 pb-1 pt-2">
{unavailable ? (
<span className="text-sm text-zinc-400">ناموجود</span>
) : (
<div className="ms-auto flex flex-col items-end leading-tight">
{product.oldPrice ? (
<span className="text-[12px] font-medium text-brand line-through">
{formatPrice(product.oldPrice)}
</span>
) : null}
<span className="text-[15px] font-bold text-[#333]">
{formatPrice(product.price)}
<span className="mr-1 text-[12px] font-normal">تومان</span>
</span>
</div>
)}
</div>
</Link>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Product } from "@/lib/data";
import { ProductCard } from "./ProductCard";
export function ProductGrid({ products }: { products: Product[] }) {
if (!products.length) {
return <p className="py-16 text-center text-sm text-zinc-500">محصولی یافت نشد.</p>;
}
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{products.map((product) => (
<div key={product.id} className="rounded-xl border border-black/5 bg-white">
<ProductCard product={product} />
</div>
))}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Image from "next/image";
import type { Product } from "@/lib/data";
import { Carousel } from "./Carousel";
import { ProductCard } from "./ProductCard";
type Props = {
id?: string;
image: string;
products: Product[];
ariaLabel: string;
tone?: "red" | "blue";
};
const gradients = {
red: "linear-gradient(90deg, #ff3b3b 0%, #fc2424 45%, #d91010 100%)",
blue: "linear-gradient(90deg, #4db3ff 0%, #1e88e5 48%, #0d5fb8 100%)",
};
export function ProductShelf({ id, image, products, ariaLabel, tone = "red" }: Props) {
return (
<section id={id} className="container-site mt-10">
<div className="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
<div className="flex items-stretch gap-3">
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
<Image src={image} alt="" fill sizes="190px" className="object-contain object-center" />
</div>
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
{products.map((product) => (
<div key={product.id} className="snap-start shrink-0 w-[180px] sm:w-[200px]">
<ProductCard product={product} />
</div>
))}
</Carousel>
</div>
</div>
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import type { ApiComment } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { SITE } from "@/lib/site";
import { RichContent } from "./RichContent";
type Props = {
productId: string;
descriptionHtml?: string | null;
comments: ApiComment[];
};
type Tab = {
id: "description" | "comments";
fa: string;
en: string;
};
const tabs: Tab[] = [
{ id: "description", fa: "توضیحات محصول", en: "Description" },
{ id: "comments", fa: "نظرات", en: "Comments" },
];
function readToken() {
if (typeof window === "undefined") return "";
return (
window.localStorage.getItem("accessToken") ||
window.localStorage.getItem("meshkee_access_token") ||
""
);
}
export function ProductTabs({ productId, descriptionHtml, comments: initialComments }: Props) {
const [tab, setTab] = useState<"description" | "comments">("description");
const [comments, setComments] = useState(initialComments);
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const authorName = "کاربر";
useEffect(() => {
setLoggedIn(Boolean(readToken()));
}, []);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!loggedIn) return;
const form = event.currentTarget;
const text = String(new FormData(form).get("text") || "").trim();
if (!text) return;
setSending(true);
try {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entityType: "product",
entityId: productId,
authorName,
text,
}),
});
if (response.ok) {
setSent(true);
form.reset();
setComments((current) => [
{
id: `local-${Date.now()}`,
authorName,
text,
createdAt: new Date().toISOString(),
},
...current,
]);
}
} finally {
setSending(false);
}
}
return (
<div className="mt-10 rounded-2xl border border-black/5">
<div className="flex justify-center gap-5 border-b border-black/5">
{tabs.map((item) => {
const active = tab === item.id;
const label = item.id === "comments" && comments.length
? `${item.fa} (${comments.length.toLocaleString("fa-IR")})`
: item.fa;
return (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={`px-2 py-3 text-center ${
active ? "border-b-2 border-brand text-brand" : "text-zinc-500"
}`}
>
<span className={`block text-base leading-none ${active ? "font-bold" : ""}`}>{label}</span>
<span dir="ltr" className="mt-0.5 block font-oswald text-[9px] font-normal leading-none tracking-[0.16em] uppercase opacity-70">
{item.en}
</span>
</button>
);
})}
</div>
<div className="p-5 md:p-8">
{tab === "description" ? (
descriptionHtml ? (
<RichContent html={descriptionHtml} />
) : (
<p className="text-sm text-zinc-500">توضیحی برای این محصول ثبت نشده است.</p>
)
) : (
<div className="space-y-6">
<div className="space-y-4">
{comments.length ? (
comments.map((comment) => (
<article key={comment.id} className="rounded-xl bg-zinc-50 p-4">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-zinc-400">
<span className="font-bold text-ink">{comment.authorName || "کاربر"}</span>
{comment.createdAt ? <time>{formatDate(comment.createdAt)}</time> : null}
</div>
<p className="text-sm leading-7 text-zinc-600">{comment.text}</p>
</article>
))
) : (
<p className="text-sm text-zinc-500">هنوز نظری ثبت نشده است.</p>
)}
</div>
{loggedIn ? (
<form onSubmit={onSubmit} className="rounded-xl border border-black/5 p-4">
{sent ? (
<p className="text-sm leading-7 text-zinc-600">نظر شما ثبت شد و پس از تایید نمایش داده می‌شود.</p>
) : (
<div className="flex gap-3">
<input
required
name="text"
placeholder="نظر خود را بنویسید..."
className="min-w-0 flex-1 rounded-xl border border-black/5 bg-zinc-50 px-4 py-2.5 text-sm outline-none focus:border-brand"
/>
<button
type="submit"
disabled={sending}
className="shrink-0 rounded-xl bg-brand px-5 py-2.5 text-sm text-white disabled:opacity-60"
>
ارسال
</button>
</div>
)}
</form>
) : (
<div className="rounded-xl bg-zinc-50 px-4 py-5 text-center">
<p className="text-sm text-zinc-600">برای ثبت نظر ابتدا وارد حساب کاربری شوید.</p>
<a
href={SITE.customerUrl}
className="mt-3 inline-block text-sm text-brand"
>
ورود / ثبت نام
</a>
</div>
)}
</div>
)}
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useMemo, useState } from "react";
import type { ApiProduct, ApiVariant } from "@/lib/api";
import { addToCart } from "@/lib/cart";
import { PLACEHOLDER } from "@/lib/data";
import { discountPercent, formatPrice } from "@/lib/format";
import { IconCart } from "./Icons";
import { Media } from "./Media";
type Props = {
product: ApiProduct;
variants: ApiVariant[];
};
export function ProductView({ product, variants }: Props) {
const images = useMemo(() => {
const urls = [
product.thumbnailUrl,
product.image,
product.thumbnail,
...(product.images ?? []).map((item) => item.url),
].filter((url): url is string => Boolean(url));
return [...new Set(urls)];
}, [product]);
const [image, setImage] = useState(images[0] || PLACEHOLDER.product);
const [variantId, setVariantId] = useState(variants[0]?.id);
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
const unavailable = !price || stock === 0;
const off = discountPercent(price, oldPrice);
return (
<div className="grid gap-6 lg:grid-cols-12 items-start">
<div className="lg:col-span-6">
<div className="relative overflow-hidden rounded-2xl border border-black/5">
{off > 0 && (
<span className="absolute top-3 left-3 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
{off}%
</span>
)}
<Media src={image} alt={product.nameFa || product.title} className="aspect-square" sizes="560px" priority />
</div>
{images.length > 1 && (
<div className="mt-3 grid grid-cols-6 gap-2">
{images.map((src) => (
<button
key={src}
type="button"
onClick={() => setImage(src)}
className={`relative aspect-square overflow-hidden rounded-lg border ${
src === image ? "border-brand" : "border-black/10"
}`}
>
<Media src={src} alt="" className="size-full" sizes="80px" />
</button>
))}
</div>
)}
</div>
<div className="rounded-2xl border border-black/5 p-5 md:p-6 lg:col-span-6">
{product.categoryNameFa && <p className="mb-2 text-xs text-zinc-400">{product.categoryNameFa}</p>}
<h1 className="text-xl md:text-2xl font-extrabold leading-9">{product.nameFa || product.title}</h1>
{product.title && product.nameFa && (
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
{product.title}
</p>
)}
{variants.length > 1 && (
<div className="mt-5">
<p className="mb-2 text-sm text-zinc-500">انتخاب تنوع</p>
<div className="flex flex-wrap gap-2">
{variants.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setVariantId(item.id)}
className={`rounded-lg border px-3 py-2 text-sm ${
item.id === variant?.id ? "border-brand text-brand" : "border-black/10 text-zinc-600"
}`}
>
{item.label || item.selections?.map((selection) => selection.value).join(" / ") || "تنوع"}
</button>
))}
</div>
</div>
)}
<div className="mt-6">
{unavailable ? (
<span className="text-lg text-zinc-400">ناموجود</span>
) : (
<div className="flex flex-col items-start gap-1">
{oldPrice ? <span className="text-sm font-medium text-brand line-through">{formatPrice(oldPrice)}</span> : null}
<span className="text-2xl font-extrabold">
{formatPrice(price)}
<span className="mr-1 text-sm font-normal">تومان</span>
</span>
</div>
)}
</div>
{!unavailable && (
<div className="mt-6 flex flex-wrap items-center gap-3">
<div className="flex items-center overflow-hidden rounded-xl border border-black/10">
<button
type="button"
aria-label="کاهش"
onClick={() => setQty((value) => Math.max(1, value - 1))}
className="grid size-10 place-items-center text-lg hover:bg-zinc-50"
>
−
</button>
<span className="min-w-10 text-center text-sm font-bold">{qty.toLocaleString("fa-IR")}</span>
<button
type="button"
aria-label="افزایش"
onClick={() => setQty((value) => Math.min(stock || 99, value + 1))}
className="grid size-10 place-items-center text-lg hover:bg-zinc-50"
>
+
</button>
</div>
<button
type="button"
onClick={() => {
addToCart({
productId: product.id,
variantId: variant?.id,
slug: product.slug,
name: product.nameFa || product.title,
image: images[0] || PLACEHOLDER.product,
price,
qty,
});
setAdded(true);
}}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm text-white hover:bg-[#e51f1f]"
>
<IconCart className="w-5 h-5 text-white" />
{added ? "به سبد اضافه شد" : "افزودن به سبد خرید"}
</button>
</div>
)}
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export function RichContent({ html }: { html?: string | null }) {
if (!html) return null;
return <div className="rich-content" dangerouslySetInnerHTML={{ __html: html }} />;
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState } from "react";
import type { Product } from "@/lib/data";
import { formatPrice } from "@/lib/format";
import { IconSearch } from "./Icons";
import { Media } from "./Media";
type Props = {
className?: string;
onNavigate?: () => void;
};
export function SearchBox({ className = "", onNavigate }: Props) {
const listId = useId();
const root = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [items, setItems] = useState<Product[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const term = query.trim();
if (term.length < 2) {
setItems([]);
setLoading(false);
setOpen(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal: controller.signal });
const data = (await response.json()) as { items?: Product[] };
setItems(data.items ?? []);
setOpen(true);
} catch (error) {
if ((error as { name?: string }).name !== "AbortError") {
setItems([]);
setOpen(true);
}
} finally {
setLoading(false);
}
}, 160);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query]);
useEffect(() => {
const onPointer = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onPointer);
return () => document.removeEventListener("mousedown", onPointer);
}, []);
return (
<div ref={root} className={`relative ${className}`}>
<form action="/products" className="flex w-full items-center">
<input
name="q"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => {
if (query.trim().length >= 2) setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
placeholder="جستجو ..."
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-controls={listId}
className="w-full min-w-0 flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-zinc-400"
/>
<button type="submit" aria-label="جستجو" className="text-zinc-500 hover:text-brand">
<IconSearch />
</button>
</form>
{open && (
<div
id={listId}
className="absolute top-[calc(100%+8px)] right-0 left-0 z-50 overflow-hidden rounded-2xl border border-black/8 bg-white shadow-xl"
>
{loading && !items.length ? (
<p className="px-4 py-3 text-sm text-zinc-400">در حال جستجو...</p>
) : items.length ? (
<ul>
{items.map((product) => {
const unavailable = product.stock === 0 || product.price === 0;
return (
<li key={product.id}>
<Link
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
onClick={onNavigate}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
>
<Media src={product.image} alt="" className="size-12 shrink-0 rounded-lg" sizes="48px" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] leading-6">{product.name}</p>
{unavailable ? (
<p className="text-xs text-zinc-400">ناموجود</p>
) : (
<p className="text-xs text-zinc-500">
{formatPrice(product.price)} <span>تومان</span>
</p>
)}
</div>
</Link>
</li>
);
})}
</ul>
) : (
<p className="px-4 py-3 text-sm text-zinc-400">نتیجه‌ای پیدا نشد.</p>
)}
<Link
href={`/products?q=${encodeURIComponent(query.trim())}`}
onClick={onNavigate}
className="block border-t border-black/5 px-4 py-2.5 text-center text-sm text-brand"
>
مشاهده همه نتایج
</Link>
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { PLACEHOLDER } from "@/lib/data";
import { getSpecialProducts } from "@/lib/mappers";
import { ProductShelf } from "./ProductShelf";
export async function SpecialSale() {
const products = await getSpecialProducts("Special-Sale");
if (!products.length) return null;
return (
<ProductShelf
id="special-sale"
image={PLACEHOLDER.specialSale}
products={products}
ariaLabel="فروش ویژه"
/>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { SITE } from "@/lib/site";
import { IconInstagram, IconPhone, IconPin, IconTelegram, IconWhatsapp } from "./Icons";
export function TopHeader() {
return (
<div className="bg-ink text-white text-xs">
<div className="container-site flex h-10 items-center justify-between gap-4">
<div className="flex items-center gap-4">
<a href={`tel:${SITE.phone}`} className="flex items-center gap-1.5 hover:text-brand-soft">
<IconPhone className="w-3.5 h-3.5 text-brand" />
<span className="font-oswald tracking-wide" dir="ltr">
{SITE.phoneDisplay}
</span>
</a>
<span className="hidden sm:flex items-center gap-1.5 text-white/70">
<IconPin className="w-3.5 h-3.5 text-brand" />
{SITE.address}
</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden md:block text-white/70">ارسال به سراسر ایران</span>
<a href={SITE.telegram} aria-label="تلگرام" className="hover:text-brand">
<IconTelegram />
</a>
<a href={SITE.instagram} aria-label="اینستاگرام" className="hover:text-brand">
<IconInstagram />
</a>
<a href={SITE.whatsapp} aria-label="واتساپ" className="hover:text-brand">
<IconWhatsapp />
</a>
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { PLACEHOLDER } from "@/lib/data";
import { getSpecialProducts } from "@/lib/mappers";
import { ProductShelf } from "./ProductShelf";
export async function TopSelling() {
const products = await getSpecialProducts("Top-Selling");
if (!products.length) return null;
return (
<ProductShelf
image={PLACEHOLDER.topSelling}
products={products}
ariaLabel="محصولات پرفروش"
tone="blue"
/>
);
}