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:
co-authored by
Claude Sonnet 5
parent
c2552af079
commit
66f92b634e
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user