Files
sanihome/src/components/product/ProductPurchasePanel.tsx
T
amirhosein.ashourlooandClaude Sonnet 5 66f92b634e 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>
2026-09-06 08:06:30 +03:30

262 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
</>
);
}