- 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>
262 lines
9.9 KiB
TypeScript
262 lines
9.9 KiB
TypeScript
"use client";
|
||
|
||
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 (
|
||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||
<path
|
||
d="M5 12.5 9.5 17 19 7.5"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function ProductPurchasePanel({
|
||
header,
|
||
variations,
|
||
storeVariants,
|
||
fallbackPrice,
|
||
inStock,
|
||
contactPhone,
|
||
storeName,
|
||
initialSelections,
|
||
}: {
|
||
header: ReactNode;
|
||
variations: ProductVariation[];
|
||
storeVariants: ProductStoreVariant[];
|
||
fallbackPrice: {
|
||
minPrice: number | null;
|
||
maxPrice: number | null;
|
||
oldPrice?: number | null;
|
||
discount?: number;
|
||
};
|
||
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;
|
||
}
|
||
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 (storeVariants.length === 0) return null;
|
||
if (variations.length === 0) return storeVariants[0] ?? null;
|
||
return (
|
||
storeVariants.find((variant) =>
|
||
variations.every((variation) => {
|
||
const selection = variant.selections.find((s) => s.variationId === variation.id);
|
||
return selection?.optionId === selected[variation.id];
|
||
}),
|
||
) ?? null
|
||
);
|
||
}, [variations, storeVariants, selected]);
|
||
|
||
const hasVariations = variations.length > 0;
|
||
const price = hasVariations
|
||
? (matchedVariant?.discountedPrice ?? matchedVariant?.price ?? null)
|
||
: fallbackPrice.minPrice;
|
||
const priceRangeLabel =
|
||
!hasVariations &&
|
||
fallbackPrice.minPrice != null &&
|
||
fallbackPrice.maxPrice != null &&
|
||
fallbackPrice.minPrice !== fallbackPrice.maxPrice
|
||
? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}`
|
||
: null;
|
||
|
||
const variantOldPrice =
|
||
matchedVariant?.discountedPrice != null &&
|
||
matchedVariant?.price != null &&
|
||
matchedVariant.price !== matchedVariant.discountedPrice
|
||
? matchedVariant.price
|
||
: null;
|
||
const oldPrice = hasVariations ? variantOldPrice : (fallbackPrice.oldPrice ?? null);
|
||
const discount = hasVariations
|
||
? (oldPrice != null && price != null ? Math.round((1 - price / oldPrice) * 100) : null)
|
||
: (fallbackPrice.discount ?? null);
|
||
|
||
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">
|
||
{header}
|
||
|
||
{hasVariations && (
|
||
<div className="mt-6 space-y-5">
|
||
{variations.map((variation) => (
|
||
<div key={variation.id}>
|
||
<p className="mb-2 text-sm font-bold">
|
||
{variation.type === "color" ? "انتخاب رنگ" : variation.name}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{variation.options.map((option) => {
|
||
const isSelected = selected[variation.id] === option.id;
|
||
const swatch = option.colorHex ?? (variation.type === "color" ? option.value : null);
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
onClick={() =>
|
||
setSelected((prev) => ({ ...prev, [variation.id]: option.id }))
|
||
}
|
||
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||
isSelected
|
||
? "border-red text-red-dark"
|
||
: "border-border text-foreground hover:border-red-muted"
|
||
}`}
|
||
>
|
||
{swatch && (
|
||
<span
|
||
className="h-3 w-3 shrink-0 rounded-full border border-border"
|
||
style={{ backgroundColor: swatch }}
|
||
aria-hidden
|
||
/>
|
||
)}
|
||
{option.label}
|
||
{isSelected && <CheckIcon className="h-3.5 w-3.5" />}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="md:col-span-3">
|
||
<div className="sticky top-24 rounded-2xl border border-border bg-surface p-5">
|
||
{storeName && <p className="mb-4 text-sm font-bold">{storeName}</p>}
|
||
|
||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||
<span className="font-medium text-foreground">موجودی:</span>
|
||
<span
|
||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||
available ? "bg-green-50 text-green-700" : "bg-red-light text-red-dark"
|
||
}`}
|
||
>
|
||
{available ? "موجود" : "ناموجود"}
|
||
</span>
|
||
</div>
|
||
|
||
{price != null ? (
|
||
<>
|
||
<div className="mb-5 space-y-1.5">
|
||
{!priceRangeLabel && oldPrice != null && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||
{discount != null && (
|
||
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-bold text-green-700">
|
||
{discount}٪
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="text-2xl font-bold text-red-dark">
|
||
{priceRangeLabel ?? formatPrice(price)}
|
||
<span className="mr-1 text-sm font-normal text-muted">تومان</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-4 flex items-center gap-3">
|
||
<span className="text-sm font-medium text-foreground">تعداد:</span>
|
||
<div className="flex items-center rounded-xl border border-border">
|
||
<button
|
||
type="button"
|
||
onClick={() => setQuantity((q) => Math.min(maxQty, q + 1))}
|
||
disabled={quantity >= maxQty}
|
||
className="px-3 py-2 text-lg text-foreground transition-colors hover:text-red disabled:cursor-not-allowed disabled:text-muted"
|
||
aria-label="افزایش تعداد"
|
||
>
|
||
+
|
||
</button>
|
||
<span className="min-w-8 text-center text-sm font-medium">{quantity}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||
disabled={quantity <= 1}
|
||
className="px-3 py-2 text-lg text-foreground transition-colors hover:text-red disabled:cursor-not-allowed disabled:text-muted"
|
||
aria-label="کاهش تعداد"
|
||
>
|
||
−
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{cartError && <p className="mb-3 text-xs text-red-dark">{cartError}</p>}
|
||
|
||
<button
|
||
type="button"
|
||
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 ? (
|
||
<a
|
||
href={`tel:${contactPhone}`}
|
||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-red py-3 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
||
>
|
||
<span aria-hidden>📞</span>
|
||
تماس با فروشگاه
|
||
</a>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className="w-full cursor-not-allowed rounded-xl bg-red/60 py-3 text-sm font-medium text-white"
|
||
>
|
||
تماس با فروشگاه
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|