Add customer auth, real cart/checkout, store-items pages, comments, contact form

- Real in-site login/register (password + SMS OTP) against Meshkee's
  /auth API, with self-healing access-token refresh (15 min lifetime) and
  an in-flight-refresh guard so AuthContext and CartContext don't race
  each other into a failed concurrent refresh.
- Real cart: header cart button/badge, a cart popup, and a working
  "add to cart" on both product cards (where a specific variant id is
  known) and the product/store-item detail buy-box (always resolves a
  purchasable variant, even for single-SKU products with no selectable
  variation).
- /checkout: address + cash/bank-transfer payment, posts to the real
  checkout API and creates an order; refreshes the cart afterward so the
  header badge doesn't go stale.
- New /store-items and /store-items/[id] pages (variant-level browsing,
  separate from the product-level /products catalog), cross-referencing
  the parent product for images/description/technical-info.
- Product gallery: click-to-open fullscreen lightbox alongside the
  existing hover-zoom.
- Comments tab wired to the real comments API, gated to logged-in users
  (a product decision — the API itself doesn't require auth) with a
  login prompt otherwise.
- Similar-products row on product/store-item detail pages.
- Contact page wired to the real contact-submissions API, with a toast
  and inline confirmation on success, and the form resetting.
- CONTEXT.md: a maintained architecture/conventions doc for this repo,
  now auto-loaded via CLAUDE.md, covering the Meshkee integration quirks
  and UI conventions established across this project.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-06 08:06:30 +03:30
co-authored by Claude Sonnet 5
parent c2552af079
commit 66f92b634e
30 changed files with 2344 additions and 37 deletions
+119 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
function ChevronIcon({ direction, className }: { direction: "prev" | "next"; className?: string }) {
const d = direction === "prev" ? "M14.5 6.5 9 12l5.5 5.5" : "M9.5 6.5 15 12l-5.5 5.5";
@@ -11,10 +12,115 @@ function ChevronIcon({ direction, className }: { direction: "prev" | "next"; cla
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function Lightbox({
images,
alt,
index,
onIndexChange,
onClose,
}: {
images: string[];
alt: string;
index: number;
onIndexChange: (i: number) => void;
onClose: () => void;
}) {
const [mounted, setMounted] = useState(false);
const hasMultiple = images.length > 1;
useEffect(() => setMounted(true), []);
useEffect(() => {
document.body.style.overflow = "hidden";
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft") onIndexChange((index + 1) % images.length);
if (event.key === "ArrowRight") onIndexChange((index - 1 + images.length) % images.length);
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", onKeyDown);
};
}, [index, images.length, onClose, onIndexChange]);
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[120] flex flex-col bg-black/95" onClick={onClose}>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="absolute top-4 left-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
>
<CloseIcon className="h-6 w-6" />
</button>
<div className="relative flex flex-1 items-center justify-center p-6" onClick={(e) => e.stopPropagation()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={images[index]} alt={alt} className="max-h-full max-w-full object-contain" />
{hasMultiple && (
<>
<button
type="button"
onClick={() => onIndexChange((index - 1 + images.length) % images.length)}
aria-label="تصویر بعدی"
className="absolute top-1/2 right-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
>
<ChevronIcon direction="prev" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => onIndexChange((index + 1) % images.length)}
aria-label="تصویر قبلی"
className="absolute top-1/2 left-4 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
>
<ChevronIcon direction="next" className="h-6 w-6" />
</button>
</>
)}
</div>
{hasMultiple && (
<div
className="flex justify-center gap-2 overflow-x-auto p-4"
onClick={(e) => e.stopPropagation()}
>
{images.map((img, i) => (
<button
key={img + i}
type="button"
onClick={() => onIndexChange(i)}
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2 transition-colors ${
i === index ? "border-red" : "border-white/20"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
)}
</div>,
document.body,
);
}
export function ProductGallery({ images, alt }: { images: string[]; alt: string }) {
const [index, setIndex] = useState(0);
const [zoomed, setZoomed] = useState(false);
const [origin, setOrigin] = useState({ x: 50, y: 50 });
const [lightboxOpen, setLightboxOpen] = useState(false);
const list = images.length > 0 ? images : ["/images/placeholder.svg"];
const hasMultiple = list.length > 1;
@@ -41,11 +147,12 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
<img
src={list[index]}
alt={alt}
onClick={() => setLightboxOpen(true)}
className="h-full w-full object-contain transition-transform duration-200 ease-out"
style={{
transform: zoomed ? "scale(2)" : "scale(1)",
transformOrigin: `${origin.x}% ${origin.y}%`,
cursor: zoomed ? "zoom-out" : "zoom-in",
cursor: zoomed ? "zoom-in" : "zoom-in",
}}
/>
@@ -89,6 +196,16 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
))}
</div>
)}
{lightboxOpen && (
<Lightbox
images={list}
alt={alt}
index={index}
onIndexChange={setIndex}
onClose={() => setLightboxOpen(false)}
/>
)}
</div>
);
}