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
@@ -3,6 +3,8 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import type { ProductStoreVariant, ProductVariation } from "@/lib/meshkee";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -26,6 +28,7 @@ export function ProductPurchasePanel({
|
||||
inStock,
|
||||
contactPhone,
|
||||
storeName,
|
||||
initialSelections,
|
||||
}: {
|
||||
header: ReactNode;
|
||||
variations: ProductVariation[];
|
||||
@@ -39,8 +42,15 @@ export function ProductPurchasePanel({
|
||||
inStock: boolean;
|
||||
contactPhone?: string;
|
||||
storeName?: string;
|
||||
// Pre-select the variant a shopper landed on directly (e.g. a store-item
|
||||
// detail page for one specific variant), instead of defaulting to the
|
||||
// first option of every variation.
|
||||
initialSelections?: Record<string, string>;
|
||||
}) {
|
||||
const { user, openLoginModal } = useAuth();
|
||||
const { addItem } = useCart();
|
||||
const [selected, setSelected] = useState<Record<string, string>>(() => {
|
||||
if (initialSelections && Object.keys(initialSelections).length > 0) return initialSelections;
|
||||
const initial: Record<string, string> = {};
|
||||
for (const variation of variations) {
|
||||
if (variation.options[0]) initial[variation.id] = variation.options[0].id;
|
||||
@@ -48,9 +58,12 @@ export function ProductPurchasePanel({
|
||||
return initial;
|
||||
});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [cartStatus, setCartStatus] = useState<"idle" | "busy" | "done">("idle");
|
||||
const [cartError, setCartError] = useState<string | null>(null);
|
||||
|
||||
const matchedVariant = useMemo(() => {
|
||||
if (variations.length === 0) return null;
|
||||
if (storeVariants.length === 0) return null;
|
||||
if (variations.length === 0) return storeVariants[0] ?? null;
|
||||
return (
|
||||
storeVariants.find((variant) =>
|
||||
variations.every((variation) => {
|
||||
@@ -84,10 +97,28 @@ export function ProductPurchasePanel({
|
||||
? (oldPrice != null && price != null ? Math.round((1 - price / oldPrice) * 100) : null)
|
||||
: (fallbackPrice.discount ?? null);
|
||||
|
||||
const stockQuantity = hasVariations ? (matchedVariant?.stockQuantity ?? 0) : null;
|
||||
const available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock;
|
||||
const stockQuantity = matchedVariant ? matchedVariant.stockQuantity : null;
|
||||
const available = matchedVariant ? stockQuantity! > 0 : inStock;
|
||||
const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99;
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!matchedVariant) return;
|
||||
if (!user) {
|
||||
openLoginModal();
|
||||
return;
|
||||
}
|
||||
setCartError(null);
|
||||
setCartStatus("busy");
|
||||
const res = await addItem(matchedVariant.id, quantity);
|
||||
if (res.ok) {
|
||||
setCartStatus("done");
|
||||
setTimeout(() => setCartStatus("idle"), 1500);
|
||||
} else {
|
||||
setCartStatus("idle");
|
||||
setCartError(res.message ?? "افزودن به سبد ناموفق بود.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:col-span-5">
|
||||
@@ -195,12 +226,15 @@ export function ProductPurchasePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cartError && <p className="mb-3 text-xs text-red-dark">{cartError}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!available}
|
||||
onClick={handleAddToCart}
|
||||
disabled={!available || cartStatus === "busy"}
|
||||
className="w-full rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark disabled:cursor-not-allowed disabled:bg-red/60"
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
{cartStatus === "done" ? "به سبد اضافه شد ✓" : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
</>
|
||||
) : contactPhone ? (
|
||||
|
||||
Reference in New Issue
Block a user