Connect the storefront to Meshkee catalog, blogs, and static image slots.
Wire homepage specials, product/blog pages, instant search, and the customer portal so the live pharmacy site can be managed from the dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cd2f9dfc9d
commit
66e604c426
@@ -1,12 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type PlaceholderProps = {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Placeholder({ src, alt = "", className }: PlaceholderProps) {
|
||||
export function Placeholder({ src, fallbackSrc, alt = "", className }: PlaceholderProps) {
|
||||
const [current, setCurrent] = useState(src);
|
||||
const failed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
failed.current = false;
|
||||
setCurrent(src);
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt} className={className} />
|
||||
<img
|
||||
src={current}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => {
|
||||
if (failed.current || !fallbackSrc || fallbackSrc === current) return;
|
||||
failed.current = true;
|
||||
setCurrent(fallbackSrc);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { parseAspectRatio } from "@/lib/static-images";
|
||||
|
||||
type SlotMediaProps = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
href?: string;
|
||||
fallbackSrc?: string;
|
||||
aspectRatio?: string;
|
||||
className?: string;
|
||||
imgClassName?: string;
|
||||
fill?: boolean;
|
||||
};
|
||||
|
||||
function wrap(href: string | undefined, children: ReactNode, className?: string) {
|
||||
if (!href) return <div className={className}>{children}</div>;
|
||||
if (href.startsWith("/") || href.startsWith("#")) {
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotMedia({
|
||||
src,
|
||||
alt = "",
|
||||
href,
|
||||
fallbackSrc,
|
||||
aspectRatio,
|
||||
className = "",
|
||||
imgClassName = "absolute inset-0 h-full w-full object-cover",
|
||||
fill = false,
|
||||
}: SlotMediaProps) {
|
||||
const image = <Placeholder src={src} fallbackSrc={fallbackSrc} alt={alt} className={imgClassName} />;
|
||||
|
||||
if (fill) {
|
||||
return wrap(href, image, `absolute inset-0 ${className}`.trim());
|
||||
}
|
||||
|
||||
const ratio = parseAspectRatio(aspectRatio);
|
||||
return wrap(
|
||||
href,
|
||||
<span className="relative block h-full w-full overflow-hidden" style={ratio ? { aspectRatio: ratio } : undefined}>
|
||||
{image}
|
||||
</span>,
|
||||
className,
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import { about } from "@/lib/data";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
export function AboutDoctor({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
|
||||
const src = image?.image ?? "/placeholders/doctor.svg";
|
||||
|
||||
export function AboutDoctor() {
|
||||
return (
|
||||
<section id="about" className="pt-10">
|
||||
<Container>
|
||||
<div className="grid items-center gap-8 overflow-hidden rounded-2xl bg-white p-5 sm:p-8 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="overflow-hidden rounded-2xl bg-sky">
|
||||
<Placeholder src="/placeholders/doctor.svg" className="aspect-[4/5] w-full object-cover lg:aspect-[4/4.4]" />
|
||||
<SlotMedia
|
||||
src={src}
|
||||
href={image?.href}
|
||||
fallbackSrc={image?.fallbackSrc ?? "/placeholders/doctor.svg"}
|
||||
aspectRatio={aspectRatio}
|
||||
className={!aspectRatio ? "aspect-[4/5] lg:aspect-[4/4.4]" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-primary">{about.subtitle}</p>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
type Banner = { id: string; image: string; href: string };
|
||||
|
||||
export function BannerRow({ banners, columns }: { banners: Banner[]; columns: 2 | 3 }) {
|
||||
export function BannerRow({
|
||||
banners,
|
||||
columns,
|
||||
aspectRatio,
|
||||
}: {
|
||||
banners?: SlotMediaItem[];
|
||||
columns: 2 | 3;
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="pt-8">
|
||||
<Container>
|
||||
<div className={`grid gap-3 ${columns === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
|
||||
{banners.map((banner) => (
|
||||
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
|
||||
<Placeholder src={banner.image} className="h-[100px] w-full object-cover sm:h-[118px]" />
|
||||
</Link>
|
||||
{(banners ?? []).map((banner) => (
|
||||
<SlotMedia
|
||||
key={banner.id}
|
||||
src={banner.image}
|
||||
href={banner.href}
|
||||
fallbackSrc={banner.fallbackSrc}
|
||||
aspectRatio={aspectRatio}
|
||||
className={`rounded-xl bg-sky ${!aspectRatio ? "h-[100px] sm:h-[118px]" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -5,10 +5,26 @@ import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { blogs } from "@/lib/data";
|
||||
import { blogs as sampleBlogs } from "@/lib/data";
|
||||
import { blogHref, blogImage, type BlogPost } from "@/lib/blog";
|
||||
import { formatFaDate } from "@/lib/format";
|
||||
|
||||
export function BlogCarousel() {
|
||||
export function BlogCarousel({ posts }: { posts?: BlogPost[] }) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const list =
|
||||
posts && posts.length
|
||||
? posts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
excerpt: post.abstract || "",
|
||||
date: formatFaDate(post.publishedAt || post.createdAt),
|
||||
image: blogImage(post),
|
||||
href: blogHref(post),
|
||||
}))
|
||||
: sampleBlogs.map((post) => ({
|
||||
...post,
|
||||
href: "/blog",
|
||||
}));
|
||||
|
||||
return (
|
||||
<section id="blog" className="pt-10">
|
||||
@@ -18,7 +34,7 @@ export function BlogCarousel() {
|
||||
<h2 className="text-[18px] font-bold text-navy">آخرین مطالب مجله سلامت</h2>
|
||||
<p className="mt-1 text-[13px] text-muted">نکتههای کاربردی از داروسازان داروخانه</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroller.current?.scrollBy({ left: 320, behavior: "smooth" })}
|
||||
@@ -35,18 +51,21 @@ export function BlogCarousel() {
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<Link href="/blog" className="hidden text-[13px] text-primary sm:block">
|
||||
مشاهده همه
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scroller} className="no-scrollbar flex snap-x gap-4 overflow-x-auto">
|
||||
{blogs.map((post) => (
|
||||
{list.map((post) => (
|
||||
<article key={post.id} className="w-[280px] shrink-0 snap-start overflow-hidden rounded-2xl bg-white sm:w-[320px]">
|
||||
<Link href="#" className="block overflow-hidden bg-sky">
|
||||
<Link href={post.href} className="block overflow-hidden bg-sky">
|
||||
<Placeholder src={post.image} className="aspect-[16/10] w-full object-cover" />
|
||||
</Link>
|
||||
<div className="p-4">
|
||||
<p className="text-[12px] text-muted">{post.date}</p>
|
||||
<h3 className="mt-2 text-[14px] font-bold leading-7 text-navy">
|
||||
<Link href="#">{post.title}</Link>
|
||||
<Link href={post.href}>{post.title}</Link>
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-2 text-[12.5px] leading-6 text-muted">{post.excerpt}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import { categories } from "@/lib/data";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
function CardWrap({ href, children }: { href?: string; children: ReactNode }) {
|
||||
const className =
|
||||
"group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5";
|
||||
if (!href) return <div className={className}>{children}</div>;
|
||||
if (href.startsWith("/") || href.startsWith("#")) {
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedCategories({
|
||||
items,
|
||||
aspectRatio,
|
||||
}: {
|
||||
items?: SlotMediaItem[];
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const list: SlotMediaItem[] =
|
||||
items && items.length > 0
|
||||
? items
|
||||
: categories.map((category) => ({
|
||||
id: category.id,
|
||||
image: category.image,
|
||||
href: category.href,
|
||||
title: category.title,
|
||||
}));
|
||||
|
||||
export function FeaturedCategories() {
|
||||
return (
|
||||
<section id="categories" className="pt-10">
|
||||
<Container>
|
||||
@@ -14,19 +50,23 @@ export function FeaturedCategories() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 md:grid-cols-8">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={category.href}
|
||||
className="group flex flex-col items-center gap-2 rounded-2xl bg-white p-3 text-center transition hover:-translate-y-0.5"
|
||||
>
|
||||
{list.map((category) => (
|
||||
<CardWrap key={category.id} href={category.href}>
|
||||
<span className="grid size-[72px] place-items-center overflow-hidden rounded-2xl bg-sky sm:size-[84px]">
|
||||
<Placeholder src={category.image} className="size-full object-cover" />
|
||||
<SlotMedia
|
||||
src={category.image}
|
||||
fallbackSrc={category.fallbackSrc}
|
||||
aspectRatio={aspectRatio}
|
||||
className="size-full overflow-hidden rounded-2xl"
|
||||
imgClassName="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
|
||||
{category.title}
|
||||
</span>
|
||||
</Link>
|
||||
{category.title ? (
|
||||
<span className="text-[12px] font-medium text-ink group-hover:text-primary sm:text-[13px]">
|
||||
{category.title}
|
||||
</span>
|
||||
) : null}
|
||||
</CardWrap>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
+38
-24
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { heroBanners } from "@/lib/data";
|
||||
import type { Slide } from "@/lib/media";
|
||||
import { SlotMedia } from "@/components/SlotMedia";
|
||||
import { parseAspectRatio } from "@/lib/static-images";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
export function Hero({ slides }: { slides: Slide[] }) {
|
||||
type HeroProps = {
|
||||
slides: SlotMediaItem[];
|
||||
banners: SlotMediaItem[];
|
||||
sliderAspectRatio?: string;
|
||||
bannerAspectRatio?: string;
|
||||
};
|
||||
|
||||
export function Hero({ slides = [], banners = [], sliderAspectRatio, bannerAspectRatio }: HeroProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const sliderRatio = parseAspectRatio(sliderAspectRatio);
|
||||
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1) return;
|
||||
@@ -27,24 +34,26 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
||||
return (
|
||||
<section className="pt-5">
|
||||
<Container>
|
||||
<div className="relative overflow-hidden rounded-2xl bg-sky">
|
||||
<div className="relative aspect-[16/6] sm:aspect-[16/5.2]">
|
||||
{slides.map((slide, i) => (
|
||||
<Link
|
||||
key={slide.id}
|
||||
href={slide.href}
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
>
|
||||
<Placeholder src={slide.image} className="h-full w-full object-cover" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl bg-sky ${sliderRatio ? "" : "aspect-[16/6] sm:aspect-[16/5.2]"}`}
|
||||
style={sliderRatio ? { aspectRatio: sliderRatio } : undefined}
|
||||
>
|
||||
{slides.map((slide, i) => (
|
||||
<SlotMedia
|
||||
key={slide.id}
|
||||
fill
|
||||
src={slide.image}
|
||||
href={slide.href}
|
||||
fallbackSrc={slide.fallbackSrc}
|
||||
className={`transition-opacity duration-500 ${i === index ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
/>
|
||||
))}
|
||||
{slides.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
className="absolute top-1/2 right-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
|
||||
className="absolute top-1/2 right-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
|
||||
aria-label="اسلاید قبلی"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
@@ -52,12 +61,12 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
className="absolute top-1/2 left-3 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
|
||||
className="absolute top-1/2 left-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-navy"
|
||||
aria-label="اسلاید بعدی"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5">
|
||||
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={slide.id}
|
||||
@@ -73,10 +82,15 @@ export function Hero({ slides }: { slides: Slide[] }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{heroBanners.map((banner) => (
|
||||
<Link key={banner.id} href={banner.href} className="overflow-hidden rounded-xl bg-sky">
|
||||
<Placeholder src={banner.image} className="h-[92px] w-full object-cover sm:h-[110px]" />
|
||||
</Link>
|
||||
{banners.map((banner) => (
|
||||
<SlotMedia
|
||||
key={banner.id}
|
||||
src={banner.image}
|
||||
href={banner.href}
|
||||
fallbackSrc={banner.fallbackSrc}
|
||||
aspectRatio={bannerAspectRatio}
|
||||
className={`overflow-hidden rounded-xl bg-sky ${!bannerAspectRatio ? "h-[92px] sm:h-[110px]" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProductCarousel({
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
href = "#",
|
||||
href = "/products",
|
||||
products,
|
||||
accent = "default",
|
||||
}: Props) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ChevronDown,
|
||||
Heart,
|
||||
Menu,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
UserRound,
|
||||
X,
|
||||
@@ -14,9 +13,12 @@ import {
|
||||
import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { MegaMenu } from "@/components/layout/MegaMenu";
|
||||
import { categories, navLinks, site } from "@/lib/data";
|
||||
import { InstantSearch } from "@/components/layout/InstantSearch";
|
||||
import { customerPortalUrl } from "@/lib/config";
|
||||
import { navLinks, site } from "@/lib/data";
|
||||
import type { NavCategory } from "@/lib/categories";
|
||||
|
||||
export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
export function Header({ logoSrc, categories }: { logoSrc: string; categories: NavCategory[] }) {
|
||||
const [megaOpen, setMegaOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobileCats, setMobileCats] = useState(false);
|
||||
@@ -37,32 +39,22 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
<Placeholder src={logoSrc} alt={site.name} className="h-11 w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
<form className="relative hidden min-w-0 flex-1 md:block">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="جستجوی دارو، مکمل و محصولات بهداشتی..."
|
||||
className="h-11 w-full rounded-xl border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
|
||||
/>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
|
||||
</form>
|
||||
<InstantSearch />
|
||||
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<Link
|
||||
href="#"
|
||||
<a
|
||||
href={customerPortalUrl}
|
||||
className="hidden items-center gap-2 rounded-xl px-3 py-2 text-[13px] text-ink hover:bg-mist lg:flex"
|
||||
>
|
||||
<UserRound className="size-5 text-navy" />
|
||||
ورود / ثبتنام
|
||||
</Link>
|
||||
<Link href="#" className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقهمندی">
|
||||
</a>
|
||||
<a href={customerPortalUrl} className="grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="علاقهمندی">
|
||||
<Heart className="size-5 text-navy" />
|
||||
</Link>
|
||||
<Link href="#" className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
|
||||
</a>
|
||||
<a href={customerPortalUrl} className="relative grid size-10 place-items-center rounded-xl hover:bg-mist" aria-label="سبد خرید">
|
||||
<ShoppingBag className="size-5 text-navy" />
|
||||
<span className="absolute top-1.5 left-1.5 grid size-4 place-items-center rounded-full bg-primary text-[10px] text-white">
|
||||
۰
|
||||
</span>
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -81,7 +73,7 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
دستهبندیها
|
||||
<ChevronDown className={`size-4 transition ${megaOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<MegaMenu open={megaOpen} />
|
||||
<MegaMenu open={megaOpen} categories={categories} />
|
||||
</div>
|
||||
|
||||
{navLinks.map((link) => (
|
||||
@@ -111,18 +103,14 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form className="px-4 pt-4">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="جستجو..."
|
||||
className="h-11 w-full rounded-xl border border-line bg-mist px-3 text-[13px] outline-none"
|
||||
/>
|
||||
</form>
|
||||
<div className="px-4 pt-4">
|
||||
<InstantSearch compact />
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-right text-[14px] font-medium text-navy"
|
||||
onClick={() => setMobileCats((v) => !v)}
|
||||
onClick={() => setMobileCats((value) => !value)}
|
||||
>
|
||||
دستهبندیها
|
||||
<ChevronDown className={`size-4 ${mobileCats ? "rotate-180" : ""}`} />
|
||||
@@ -131,9 +119,19 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
<ul className="mb-2 space-y-1 pr-3">
|
||||
{categories.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] text-muted">
|
||||
<Link href={cat.href} className="block rounded-lg px-3 py-1.5 text-[13px] font-medium text-navy" onClick={() => setMobileOpen(false)}>
|
||||
{cat.title}
|
||||
</Link>
|
||||
{cat.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={child.href}
|
||||
className="block rounded-lg px-3 py-1.5 pr-5 text-[13px] text-muted"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{child.title}
|
||||
</Link>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -148,6 +146,9 @@ export function Header({ logoSrc }: { logoSrc: string }) {
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
<a href={customerPortalUrl} className="mt-2 block rounded-xl px-3 py-2.5 text-[14px] text-primary">
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search } from "lucide-react";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { variantTitle, type StoreVariant } from "@/lib/store";
|
||||
|
||||
export function InstantSearch({ compact = false }: { compact?: boolean }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<StoreVariant[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!box.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const value = query.trim();
|
||||
if (value.length <= 2) {
|
||||
setItems([]);
|
||||
setOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
|
||||
const data = (await response.json()) as { items?: StoreVariant[] };
|
||||
setItems(data.items ?? []);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div ref={box} className={`relative ${compact ? "w-full" : "hidden min-w-0 flex-1 md:block"}`}>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => {
|
||||
if (query.trim().length > 2) setOpen(true);
|
||||
}}
|
||||
placeholder={compact ? "جستجو..." : "جستجوی دارو، مکمل و محصولات بهداشتی..."}
|
||||
className="h-11 w-full rounded-xl border border-line bg-mist pr-4 pl-11 text-[13px] outline-none transition focus:border-primary focus:bg-white"
|
||||
/>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted" />
|
||||
|
||||
{open ? (
|
||||
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-50 overflow-hidden rounded-2xl border border-line bg-white shadow-[0_16px_40px_rgba(0,38,133,0.12)]">
|
||||
{loading ? (
|
||||
<p className="px-4 py-3 text-[13px] text-muted">در حال جستجو...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-4 py-3 text-[13px] text-muted">نتیجهای پیدا نشد.</p>
|
||||
) : (
|
||||
<ul className="max-h-[360px] overflow-y-auto py-2">
|
||||
{items.map((item) => {
|
||||
const price = item.discountedPrice ?? item.price;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={`/products/${encodeURIComponent(item.id)}`}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-mist"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="grid size-12 shrink-0 overflow-hidden rounded-lg bg-sky">
|
||||
<Placeholder
|
||||
src={item.productImage || "/placeholders/product.svg"}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-1 text-[13px] text-ink">{variantTitle(item)}</span>
|
||||
<span className="mt-0.5 block text-[12px] text-muted">
|
||||
{price != null ? `${formatPrice(price)} تومان` : "تماس بگیرید"}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { categories } from "@/lib/data";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import type { NavCategory } from "@/lib/categories";
|
||||
|
||||
export function MegaMenu({ open }: { open: boolean }) {
|
||||
export function MegaMenu({ open, categories }: { open: boolean; categories: NavCategory[] }) {
|
||||
if (!open) return null;
|
||||
|
||||
const list = categories.length ? categories : [];
|
||||
|
||||
return (
|
||||
<div className="absolute top-full right-0 z-50 mt-2 w-[min(920px,calc(100vw-2rem))] rounded-2xl border border-line bg-white p-5 shadow-[0_16px_40px_rgba(0,38,133,0.12)]">
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id}>
|
||||
<Link
|
||||
href={category.href}
|
||||
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
|
||||
>
|
||||
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
|
||||
<Placeholder src={category.image} className="size-full object-cover" />
|
||||
</span>
|
||||
{category.title}
|
||||
</Link>
|
||||
<ul className="space-y-1.5 pr-1">
|
||||
{category.children.map((child) => (
|
||||
<li key={child.title}>
|
||||
<Link
|
||||
href={child.href}
|
||||
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
|
||||
>
|
||||
{child.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-[13px] text-muted">دستهبندیها بهزودی اضافه میشوند.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4">
|
||||
{list.map((category) => (
|
||||
<div key={category.id}>
|
||||
<Link
|
||||
href={category.href}
|
||||
className="mb-3 flex items-center gap-2 text-[13.5px] font-semibold text-navy"
|
||||
>
|
||||
<span className="grid size-9 place-items-center overflow-hidden rounded-xl bg-sky">
|
||||
<Placeholder src={category.image || "/placeholders/category.svg"} className="size-full object-cover" />
|
||||
</span>
|
||||
{category.title}
|
||||
</Link>
|
||||
{category.children.length ? (
|
||||
<ul className="space-y-1.5 pr-1">
|
||||
{category.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
href={child.href}
|
||||
className="block rounded-lg px-2 py-1 text-[12.5px] text-muted transition hover:bg-sky hover:text-primary"
|
||||
>
|
||||
{child.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Minus, Plus, ShoppingBag } from "lucide-react";
|
||||
import { customerPortalUrl } from "@/lib/config";
|
||||
|
||||
export function AddToCart({ variantId, maxQuantity }: { variantId: string; maxQuantity?: number | null }) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
const cap = maxQuantity && maxQuantity > 0 ? maxQuantity : 99;
|
||||
|
||||
const change = (delta: number) => {
|
||||
setQuantity((current) => Math.min(cap, Math.max(1, current + delta)));
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
const token =
|
||||
window.localStorage.getItem("accessToken") ||
|
||||
window.localStorage.getItem("meshkeeToken") ||
|
||||
window.localStorage.getItem("token");
|
||||
|
||||
if (!token) {
|
||||
window.location.href = customerPortalUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const response = await fetch("/api/cart/items", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ storeItemVariantId: variantId, quantity }),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.href = customerPortalUrl;
|
||||
return;
|
||||
}
|
||||
setStatus(response.ok ? "ok" : "error");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex h-12 items-center rounded-xl border border-line bg-white">
|
||||
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(-1)} aria-label="کاهش">
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<span className="min-w-[2rem] text-center text-[15px] font-bold text-navy">{quantity}</span>
|
||||
<button type="button" className="grid size-12 place-items-center text-navy" onClick={() => change(1)} aria-label="افزایش">
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={status === "loading"}
|
||||
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-[14px] font-bold text-white transition hover:bg-primary-hover disabled:opacity-70"
|
||||
>
|
||||
<ShoppingBag className="size-4" />
|
||||
{status === "loading" ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
{status === "ok" ? <p className="text-[12px] text-ok">به سبد اضافه شد.</p> : null}
|
||||
{status === "error" ? <p className="text-[12px] text-sale">افزودن به سبد انجام نشد.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
|
||||
export function ProductGallery({ images, alt }: { images: string[]; alt: string }) {
|
||||
const gallery = images.length ? images : ["/placeholders/product.svg"];
|
||||
const [active, setActive] = useState(0);
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const [origin, setOrigin] = useState("50% 50%");
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const current = gallery[active] ?? gallery[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative block w-full overflow-hidden rounded-2xl bg-sky"
|
||||
onClick={() => setLightbox(true)}
|
||||
onMouseEnter={() => setZoomed(true)}
|
||||
onMouseLeave={() => {
|
||||
setZoomed(false);
|
||||
setOrigin("50% 50%");
|
||||
}}
|
||||
onMouseMove={(event) => {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - box.left) / box.width) * 100;
|
||||
const y = ((event.clientY - box.top) / box.height) * 100;
|
||||
setOrigin(`${x}% ${y}%`);
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
src={current}
|
||||
fallbackSrc="/placeholders/product.svg"
|
||||
alt={alt}
|
||||
className="aspect-square w-full object-cover transition-transform duration-150"
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${current})`,
|
||||
transform: zoomed ? "scale(1.85)" : "scale(1)",
|
||||
transformOrigin: origin,
|
||||
opacity: zoomed ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{gallery.length > 1 ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto">
|
||||
{gallery.map((image, index) => (
|
||||
<button
|
||||
key={`${image}-${index}`}
|
||||
type="button"
|
||||
onClick={() => setActive(index)}
|
||||
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-primary" : "border-line"}`}
|
||||
>
|
||||
<Placeholder src={image} className="size-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lightbox ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-navy/80 p-4" onClick={() => setLightbox(false)}>
|
||||
<button type="button" className="absolute top-4 left-4 grid size-10 place-items-center rounded-full bg-white text-navy" aria-label="بستن">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<img
|
||||
src={current}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { customerPortalUrl } from "@/lib/config";
|
||||
import { formatFaDate } from "@/lib/format";
|
||||
import type { CommentItem } from "@/lib/comments";
|
||||
|
||||
export function ProductTabs({
|
||||
descriptionHtml,
|
||||
comments,
|
||||
showDescription = true,
|
||||
}: {
|
||||
descriptionHtml?: string | null;
|
||||
comments: CommentItem[];
|
||||
showDescription?: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"description" | "comments">(showDescription ? "description" : "comments");
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setToken(
|
||||
window.localStorage.getItem("accessToken") ||
|
||||
window.localStorage.getItem("meshkeeToken") ||
|
||||
window.localStorage.getItem("token"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-2xl bg-white p-5 sm:p-7">
|
||||
{showDescription ? (
|
||||
<div className="flex gap-2 border-b border-line pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("description")}
|
||||
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "description" ? "bg-sky text-navy" : "text-muted"}`}
|
||||
>
|
||||
توضیحات
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("comments")}
|
||||
className={`rounded-lg px-4 py-2 text-[13px] font-medium ${tab === "comments" ? "bg-sky text-navy" : "text-muted"}`}
|
||||
>
|
||||
نظرات
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="text-[16px] font-bold text-navy">نظرات</h2>
|
||||
)}
|
||||
|
||||
{tab === "description" ? (
|
||||
descriptionHtml ? (
|
||||
<div className="rich-text mt-5 text-[14px] leading-8 text-ink" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||
) : (
|
||||
<p className="mt-5 text-[13px] text-muted">توضیحاتی برای این محصول ثبت نشده است.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-5 space-y-4">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-[13px] text-muted">نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
comments.map((comment, index) => (
|
||||
<article key={comment.id ?? index} className="rounded-xl bg-mist p-4">
|
||||
<p className="text-[13px] font-bold text-navy">{comment.authorName || "کاربر"}</p>
|
||||
{comment.createdAt ? <p className="mt-1 text-[11px] text-muted">{formatFaDate(comment.createdAt)}</p> : null}
|
||||
<p className="mt-2 text-[13px] leading-7 text-ink">{comment.text}</p>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
|
||||
{token ? (
|
||||
<p className="text-[13px] text-muted">برای مدیریت نظرات از حساب کاربری خود استفاده کنید.</p>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-sky p-5">
|
||||
<p className="text-[14px] font-bold text-navy">برای ثبت نظر وارد شوید</p>
|
||||
<p className="mt-1 text-[13px] text-muted">نظرات فقط برای کاربران واردشده فعال است.</p>
|
||||
<a
|
||||
href={customerPortalUrl}
|
||||
className="mt-4 inline-flex h-11 items-center rounded-xl bg-primary px-5 text-[13px] font-bold text-white"
|
||||
>
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,19 +5,25 @@ import { faDigits, formatPrice } from "@/lib/format";
|
||||
import type { Product } from "@/lib/data";
|
||||
|
||||
export function ProductCard({ product }: { product: Product }) {
|
||||
const href = product.href ?? `/products/${encodeURIComponent(product.id)}`;
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col rounded-2xl bg-white p-3">
|
||||
<Link href="#" className="relative overflow-hidden rounded-xl bg-sky">
|
||||
<Link href={href} className="relative overflow-hidden rounded-xl bg-sky">
|
||||
{product.discount ? (
|
||||
<span className="absolute top-2 right-2 z-10 rounded-md bg-sale px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||
{faDigits(product.discount)}٪
|
||||
</span>
|
||||
) : null}
|
||||
<Placeholder src={product.image} className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
|
||||
<Placeholder src={product.image} fallbackSrc="/placeholders/product.svg" className="aspect-square w-full object-cover transition group-hover:scale-[1.03]" />
|
||||
</Link>
|
||||
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
|
||||
{product.brand ? (
|
||||
<p className="font-latin mt-3 text-[11px] tracking-wide text-primary">{product.brand}</p>
|
||||
) : (
|
||||
<p className="mt-3 h-[16px]" />
|
||||
)}
|
||||
<h3 className="mt-1 line-clamp-2 min-h-[40px] text-[13px] leading-5 text-ink">
|
||||
<Link href="#">{product.title}</Link>
|
||||
<Link href={href}>{product.title}</Link>
|
||||
</h3>
|
||||
<div className="mt-auto pt-3">
|
||||
{product.oldPrice ? (
|
||||
@@ -27,16 +33,22 @@ export function ProductCard({ product }: { product: Product }) {
|
||||
)}
|
||||
<div className="mt-1 flex items-end justify-between gap-2">
|
||||
<p className="text-[14px] font-bold text-navy">
|
||||
{formatPrice(product.price)}
|
||||
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
|
||||
{product.price != null ? (
|
||||
<>
|
||||
{formatPrice(product.price)}
|
||||
<span className="mr-1 text-[11px] font-medium text-muted">تومان</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[12px] font-medium text-muted">تماس بگیرید</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
href={href}
|
||||
className="grid size-9 place-items-center rounded-xl bg-primary text-white transition hover:bg-primary-hover"
|
||||
aria-label="افزودن به سبد خرید"
|
||||
aria-label="مشاهده محصول"
|
||||
>
|
||||
<ShoppingBag className="size-4" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
Reference in New Issue
Block a user