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:
Alireza Hassani
2026-08-19 23:51:41 +03:30
co-authored by Cursor
parent cd2f9dfc9d
commit 66e604c426
39 changed files with 1900 additions and 188 deletions
+71
View File
@@ -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>
);
}
+81
View File
@@ -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}
</>
);
}
+89
View File
@@ -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>
);
}