Add installment calculator and Latin digits in English product text.
Wire the product CTA to store-loan-methods calculate so shoppers can pick a term and see the plan, and render English model runs under Montserrat so fanum fonts stop painting 0-9 as Persian digits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7b8b5fd723
commit
e625354dc9
@@ -327,3 +327,13 @@ identity fix, live logo/favicon wiring) once sat 3 commits ahead of
|
||||
`origin/main` unpushed, and the business reported bugs that were already
|
||||
fixed locally — they were looking at the old deployed code. Check
|
||||
`git status` for "ahead of origin" before telling anyone a fix is live.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
+3
-1
@@ -60,9 +60,11 @@
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
/* Latin runs (model names, prices in EN digits) inside Farsi copy. */
|
||||
/* Latin runs (model names, SKUs) inside Farsi copy — Montserrat keeps
|
||||
real 0-9 digits; IRANYekan fanum would otherwise paint them as ۰-۹. */
|
||||
.en {
|
||||
font-family: var(--font-en), var(--font-fa), sans-serif;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Swiper theming: its own CSS reads these custom properties when drawing
|
||||
|
||||
@@ -77,10 +77,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
||||
<ProductGallery images={images} title={title} />
|
||||
<ProductBuyPanel product={product} variants={variants} />
|
||||
</div>
|
||||
<ProductBuyPanel
|
||||
product={product}
|
||||
variants={variants}
|
||||
gallery={<ProductGallery images={images} title={title} />}
|
||||
/>
|
||||
|
||||
<ProductInfoTabs
|
||||
title={title}
|
||||
|
||||
@@ -91,10 +91,12 @@ export default async function StoreItemDetailPage({ params }: { params: Promise<
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
||||
<ProductGallery images={images} title={title} />
|
||||
<ProductBuyPanel product={product} variants={variants} initialVariantId={variant.id} />
|
||||
</div>
|
||||
<ProductBuyPanel
|
||||
product={product}
|
||||
variants={variants}
|
||||
initialVariantId={variant.id}
|
||||
gallery={<ProductGallery images={images} title={title} />}
|
||||
/>
|
||||
|
||||
<ProductInfoTabs
|
||||
title={title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import MixedText from "./MixedText";
|
||||
import { ChevronLeft } from "./icons";
|
||||
|
||||
export interface Crumb {
|
||||
@@ -14,10 +15,12 @@ export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
|
||||
{item.href ? (
|
||||
<Link href={item.href} className="transition hover:text-brand">
|
||||
{item.label}
|
||||
<MixedText>{item.label}</MixedText>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-ink-soft">{item.label}</span>
|
||||
<span className="text-ink-soft">
|
||||
<MixedText>{item.label}</MixedText>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckIcon, CloseIcon, WalletIcon } from "./icons";
|
||||
import { clampCreditAmount, fetchStoreLoanMethods, fetchStoreLoanPlan } from "@/lib/loanMethods";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import type { StoreLoanMethod, StoreLoanPlan } from "@/lib/types";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
totalValue: number | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function formatDueDate(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoanCalculatorModal({ open, totalValue, onClose }: Props) {
|
||||
const [methods, setMethods] = useState<StoreLoanMethod[]>([]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [loadingMethods, setLoadingMethods] = useState(false);
|
||||
const [methodId, setMethodId] = useState<string | null>(null);
|
||||
const [months, setMonths] = useState(0);
|
||||
const [creditAmount, setCreditAmount] = useState(0);
|
||||
const [plan, setPlan] = useState<StoreLoanPlan | null>(null);
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const method = useMemo(
|
||||
() => methods.find((m) => m.id === methodId) ?? null,
|
||||
[methods, methodId],
|
||||
);
|
||||
|
||||
const monthOptions = method?.returnMonthOptions?.length
|
||||
? method.returnMonthOptions
|
||||
: method
|
||||
? Array.from(
|
||||
{ length: Math.floor((method.returnMonthsMax - method.returnMonthsMin) / method.returnMonthsStep) + 1 },
|
||||
(_, i) => method.returnMonthsMin + i * method.returnMonthsStep,
|
||||
)
|
||||
: [];
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Load methods when opened.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
setLoadingMethods(true);
|
||||
setError(null);
|
||||
setPlan(null);
|
||||
fetchStoreLoanMethods(controller.signal)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setEnabled(data.enabled);
|
||||
setMethods(data.items);
|
||||
if (data.items.length === 1) {
|
||||
setMethodId(data.items[0]!.id);
|
||||
} else if (data.items.length > 1) {
|
||||
setMethodId(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled || (err as Error).name === "AbortError") return;
|
||||
setEnabled(false);
|
||||
setMethods([]);
|
||||
setError("دریافت روشهای اقساط ممکن نشد.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingMethods(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// When method or price changes, seed credit + months.
|
||||
useEffect(() => {
|
||||
if (!method || totalValue === null) return;
|
||||
const credit = clampCreditAmount(
|
||||
totalValue,
|
||||
totalValue,
|
||||
method.minAmount,
|
||||
method.maxAmount,
|
||||
);
|
||||
setCreditAmount(credit);
|
||||
const options = method.returnMonthOptions?.length
|
||||
? method.returnMonthOptions
|
||||
: [method.returnMonthsMin];
|
||||
setMonths(options[0] ?? method.returnMonthsMin);
|
||||
setPlan(null);
|
||||
}, [method, totalValue]);
|
||||
|
||||
// Calculate whenever inputs are ready.
|
||||
useEffect(() => {
|
||||
if (!open || !method || totalValue === null || months < 1 || creditAmount < 1) return;
|
||||
if (totalValue < method.minShoppingAmount) {
|
||||
setPlan(null);
|
||||
setError(
|
||||
`حداقل مبلغ خرید برای این طرح ${formatPrice(method.minShoppingAmount)} تومان است.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
setCalculating(true);
|
||||
setError(null);
|
||||
fetchStoreLoanPlan(
|
||||
{
|
||||
methodId: method.id,
|
||||
totalValue,
|
||||
creditAmount,
|
||||
months,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPlan(data.plan);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled || (err as Error).name === "AbortError") return;
|
||||
setPlan(null);
|
||||
setError((err as Error).message || "محاسبه اقساط ناموفق بود.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCalculating(false);
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, method, totalValue, creditAmount, months]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
const needsMethodPick = methods.length > 1 && !methodId;
|
||||
const priceLabel = totalValue !== null ? formatPrice(totalValue) : "—";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-ink/45 p-0 sm:items-center sm:p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-lg flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:rounded-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="loan-calc-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-[12px] font-bold text-brand">
|
||||
<WalletIcon className="size-4" />
|
||||
محاسبه اقساط
|
||||
</p>
|
||||
<h2 id="loan-calc-title" className="mt-1 text-lg font-bold text-ink">
|
||||
این محصول را اقساطی بخرید
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[13px] text-body">
|
||||
مبلغ کالا: <span className="font-bold text-ink">{priceLabel}</span>
|
||||
{totalValue !== null && <span className="mr-1">تومان</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-line text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
<CloseIcon className="size-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-5">
|
||||
{loadingMethods && (
|
||||
<p className="py-8 text-center text-sm text-body">در حال دریافت روشهای اقساط…</p>
|
||||
)}
|
||||
|
||||
{!loadingMethods && (!enabled || methods.length === 0) && (
|
||||
<p className="rounded-xl bg-surface px-4 py-6 text-center text-sm text-body">
|
||||
در حال حاضر فروش اقساطی برای این فروشگاه فعال نیست.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loadingMethods && needsMethodPick && (
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-bold text-ink">انتخاب طرح اقساط</h3>
|
||||
<div className="space-y-2">
|
||||
{methods.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setMethodId(m.id)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-line p-3 text-start transition hover:border-brand"
|
||||
>
|
||||
<span className="relative size-12 shrink-0 overflow-hidden rounded-xl bg-surface">
|
||||
{m.imageUrl ? (
|
||||
<Image src={m.imageUrl} alt="" fill sizes="48px" className="object-cover" />
|
||||
) : (
|
||||
<span className="flex size-full items-center justify-center text-brand">
|
||||
<WalletIcon className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-bold text-ink">{m.nameFa || m.nameEn}</span>
|
||||
{m.description && (
|
||||
<span className="mt-0.5 block text-[12px] text-body">{m.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loadingMethods && method && (
|
||||
<>
|
||||
{methods.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethodId(null);
|
||||
setPlan(null);
|
||||
}}
|
||||
className="text-[12.5px] font-bold text-brand"
|
||||
>
|
||||
تغییر طرح: {method.nameFa || method.nameEn}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{methods.length === 1 && (
|
||||
<p className="rounded-xl bg-brand-soft px-3 py-2.5 text-[13px] font-bold text-brand">
|
||||
{method.nameFa || method.nameEn}
|
||||
{method.description ? ` — ${method.description}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-ink">مدت بازپرداخت</h3>
|
||||
<span className="text-sm font-black text-brand">
|
||||
{toFaDigits(months)} ماه
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{monthOptions.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMonths(m)}
|
||||
className={`h-10 min-w-12 rounded-xl px-3 text-sm font-bold transition ${
|
||||
months === m
|
||||
? "bg-brand text-white"
|
||||
: "border border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{toFaDigits(m)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[12px] text-body">
|
||||
سود سالانه {toFaDigits(method.interest)}٪ · اعتبار از{" "}
|
||||
{formatPrice(method.minAmount)} تا {formatPrice(method.maxAmount)} تومان
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{totalValue !== null && totalValue > method.minAmount && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-ink">مبلغ اعتبار (قسطی)</h3>
|
||||
<span className="text-sm font-bold text-ink">
|
||||
{formatPrice(creditAmount)}
|
||||
<span className="mr-1 text-[11px] font-normal text-body">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={Math.min(method.minAmount, totalValue)}
|
||||
max={Math.min(method.maxAmount, totalValue)}
|
||||
step={100_000}
|
||||
value={creditAmount}
|
||||
onChange={(e) =>
|
||||
setCreditAmount(
|
||||
clampCreditAmount(
|
||||
Number(e.target.value),
|
||||
totalValue,
|
||||
method.minAmount,
|
||||
method.maxAmount,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-full accent-[var(--color-brand)]"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-body">
|
||||
<span>نقدی: {formatPrice(Math.max(0, totalValue - creditAmount))} تومان</span>
|
||||
<span>حداکثر اعتبار</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-xl bg-brand-soft px-3 py-2.5 text-[13px] text-brand" role="status">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{calculating && !plan && (
|
||||
<p className="text-center text-sm text-body">در حال محاسبه…</p>
|
||||
)}
|
||||
|
||||
{plan && !error && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-ink">جزئیات وام</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SummaryCard label="پرداخت نقدی" value={plan.cashPayment} />
|
||||
<SummaryCard label="مبلغ اعتبار" value={plan.creditAmount} />
|
||||
<SummaryCard label="کارمزد / سود" value={plan.interestFee} />
|
||||
<SummaryCard label="جمع بازپرداخت" value={plan.totalRepay} emphasize />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-line">
|
||||
<div className="flex items-center justify-between bg-surface px-3 py-2.5 text-[12.5px] font-bold text-ink">
|
||||
<span>جدول اقساط</span>
|
||||
<span>{toFaDigits(plan.installments.length)} قسط</span>
|
||||
</div>
|
||||
<ul className="max-h-56 divide-y divide-line overflow-y-auto">
|
||||
{plan.installments.map((item) => (
|
||||
<li
|
||||
key={item.index}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2.5 text-[12.5px]"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-body">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-brand-soft text-[11px] font-bold text-brand">
|
||||
{toFaDigits(item.index)}
|
||||
</span>
|
||||
{formatDueDate(item.dueDate)}
|
||||
</span>
|
||||
<span className="shrink-0 font-bold text-ink">
|
||||
{formatPrice(item.amount)}
|
||||
<span className="mr-1 text-[10px] font-normal text-body">تومان</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan && !error && (
|
||||
<div className="border-t border-line px-5 py-4">
|
||||
<p className="flex items-center justify-center gap-1.5 text-[12.5px] text-body">
|
||||
<CheckIcon className="size-4 text-brand" />
|
||||
برای نهایی کردن خرید اقساطی با فروشگاه هماهنگ کنید
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
emphasize,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
emphasize?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl px-3 py-3 ${
|
||||
emphasize ? "col-span-2 bg-brand text-white" : "bg-surface text-ink"
|
||||
}`}
|
||||
>
|
||||
<p className={`text-[11px] ${emphasize ? "text-white/80" : "text-body"}`}>{label}</p>
|
||||
<p className="mt-0.5 text-sm font-black">
|
||||
{formatPrice(value)}
|
||||
<span className={`mr-1 text-[10px] font-normal ${emphasize ? "text-white/80" : "text-body"}`}>
|
||||
تومان
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { splitMixedText } from "@/lib/mixedText";
|
||||
|
||||
export default function MixedText({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const nodes: ReactNode[] = splitMixedText(children).map((seg, i) =>
|
||||
seg.type === "en" ? (
|
||||
<span key={i} className="en" dir="ltr" lang="en">
|
||||
{seg.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
),
|
||||
);
|
||||
|
||||
if (className) return <span className={className}>{nodes}</span>;
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { CartIcon, CheckIcon, ChevronLeft, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "./icons";
|
||||
import LoanCalculatorModal from "./LoanCalculatorModal";
|
||||
import MixedText from "./MixedText";
|
||||
import { useCart } from "./providers/CartProvider";
|
||||
import { useToast } from "./providers/ToastProvider";
|
||||
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
|
||||
@@ -25,17 +27,22 @@ export default function ProductBuyPanel({
|
||||
product,
|
||||
variants = [],
|
||||
initialVariantId,
|
||||
gallery,
|
||||
}: {
|
||||
product: ApiProduct;
|
||||
variants?: StoreSpecialVariant[];
|
||||
/** Preselect a specific variant — used when entering via its own
|
||||
* /store-items/{id} URL rather than the product page. */
|
||||
initialVariantId?: string;
|
||||
/** Optional gallery slot — when provided, the installment CTA spans the
|
||||
* full product row (gallery + buy columns). */
|
||||
gallery?: ReactNode;
|
||||
}) {
|
||||
const inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
|
||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [added, setAdded] = useState(false);
|
||||
const [loanOpen, setLoanOpen] = useState(false);
|
||||
const { addItem } = useCart();
|
||||
const { showToast } = useToast();
|
||||
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
||||
@@ -85,16 +92,20 @@ export default function ProductBuyPanel({
|
||||
setTimeout(() => setAdded(false), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
const buyColumn = (
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-ink md:text-2xl">{title}</h1>
|
||||
<h1 className="text-xl font-bold text-ink md:text-2xl">
|
||||
<MixedText>{title}</MixedText>
|
||||
</h1>
|
||||
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
|
||||
|
||||
{product.summary && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p>
|
||||
<p className="text-sm leading-7 text-body">{product.summary}</p>
|
||||
<p className="text-sm leading-7 text-body">
|
||||
<MixedText>{product.summary}</MixedText>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -246,4 +257,45 @@ export default function ProductBuyPanel({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const installmentRow = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoanOpen(true)}
|
||||
disabled={price === null}
|
||||
className="group flex w-full items-center gap-4 overflow-hidden rounded-2xl bg-brand px-5 py-4 text-start text-white shadow-[0_8px_24px_-8px_rgba(229,30,34,0.55)] transition hover:bg-brand-dark disabled:cursor-not-allowed disabled:opacity-60 sm:px-6 sm:py-5"
|
||||
>
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-white text-brand sm:size-14">
|
||||
<WalletIcon className="size-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-base font-black leading-7 sm:text-lg">این محصول را اقساطی بخرید</span>
|
||||
<span className="mt-0.5 block text-[12.5px] font-bold text-white/85 sm:text-[13px]">
|
||||
انتخاب مدت بازپرداخت و مشاهده جزئیات اقساط
|
||||
</span>
|
||||
</span>
|
||||
<ChevronLeft className="size-5 shrink-0 transition group-hover:-translate-x-0.5 sm:size-6" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{gallery ? (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
|
||||
{gallery}
|
||||
{buyColumn}
|
||||
</div>
|
||||
{installmentRow}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{buyColumn}
|
||||
{installmentRow}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoanCalculatorModal open={loanOpen} totalValue={price} onClose={() => setLoanOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import CommentSection from "./CommentSection";
|
||||
import MixedText from "./MixedText";
|
||||
import { wrapLatinInHtml } from "@/lib/mixedText";
|
||||
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
|
||||
|
||||
type Tab = "description" | "technical" | "reviews";
|
||||
@@ -49,11 +51,13 @@ export default function ProductInfoTabs({
|
||||
<div className="pt-8">
|
||||
{tab === "description" && (
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-bold text-ink md:text-xl">معرفی {title}</h2>
|
||||
<h2 className="mb-4 text-lg font-bold text-ink md:text-xl">
|
||||
معرفی <MixedText>{title}</MixedText>
|
||||
</h2>
|
||||
{descriptionHtml ? (
|
||||
<div
|
||||
className="rich-text text-sm leading-8 text-body"
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
dangerouslySetInnerHTML={{ __html: wrapLatinInHtml(descriptionHtml) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
|
||||
@@ -67,8 +71,12 @@ export default function ProductInfoTabs({
|
||||
<dl className="divide-y divide-line overflow-hidden rounded-xl border border-line">
|
||||
{specRows.map((row) => (
|
||||
<div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
|
||||
<dt className="text-body">{row.label}</dt>
|
||||
<dd className="font-bold text-ink">{row.value}</dd>
|
||||
<dt className="text-body">
|
||||
<MixedText>{row.label}</MixedText>
|
||||
</dt>
|
||||
<dd className="font-bold text-ink">
|
||||
<MixedText>{row.value}</MixedText>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SearchIcon } from "./icons";
|
||||
import MixedText from "./MixedText";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { productHref } from "@/lib/slug";
|
||||
import type { ApiProduct } from "@/lib/types";
|
||||
@@ -110,7 +111,9 @@ export default function SearchBox() {
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="clamp-2 text-[13px] leading-6">{p.nameFa || p.title}</span>
|
||||
<span className="clamp-2 text-[13px] leading-6">
|
||||
<MixedText>{p.nameFa || p.title}</MixedText>
|
||||
</span>
|
||||
</span>
|
||||
{p.store.minPrice !== null && (
|
||||
<span className="shrink-0 text-[12px] font-bold text-ink">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import MixedText from "@/components/MixedText";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import type { ProductCardData } from "@/lib/types";
|
||||
|
||||
@@ -34,7 +35,7 @@ export default function ProductCard({ product }: { product: ProductCardData }) {
|
||||
</span>
|
||||
|
||||
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
|
||||
{title}
|
||||
<MixedText>{title}</MixedText>
|
||||
</span>
|
||||
|
||||
{/* The old-price line always occupies its row — rendered empty when there
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
StaticImageSlot,
|
||||
StoreItem,
|
||||
StoreItemVariants,
|
||||
StoreLoanCalculateResponse,
|
||||
StoreLoanMethodsResponse,
|
||||
StoreSpecial,
|
||||
Tenant,
|
||||
TechnicalInfoForm,
|
||||
@@ -224,6 +226,21 @@ export function submitContactForm(
|
||||
return apiPost(t("/contact-submissions"), data);
|
||||
}
|
||||
|
||||
// Installments module — AI_PROMPT.md "Installments & credit methods".
|
||||
// Hide the CTA when enabled:false or items is empty.
|
||||
export function getStoreLoanMethods(): Promise<StoreLoanMethodsResponse> {
|
||||
return apiGet(t("/store-loan-methods"));
|
||||
}
|
||||
|
||||
export function calculateStoreLoan(input: {
|
||||
methodId: string;
|
||||
totalValue: number;
|
||||
creditAmount: number;
|
||||
months: number;
|
||||
}): Promise<StoreLoanCalculateResponse> {
|
||||
return apiPost(t("/store-loan-methods/calculate"), input);
|
||||
}
|
||||
|
||||
// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and
|
||||
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
|
||||
// customer-dashboard-only. The mini-cart is local-only; see
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { API_BASE, SITE_DOMAIN } from "./config";
|
||||
import type { StoreLoanCalculateResponse, StoreLoanMethodsResponse } from "./types";
|
||||
|
||||
const base = `${API_BASE}/tenants/${SITE_DOMAIN}/store-loan-methods`;
|
||||
|
||||
/** Browser-side list — same contract as GET .../store-loan-methods. */
|
||||
export async function fetchStoreLoanMethods(
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoreLoanMethodsResponse> {
|
||||
const res = await fetch(base, { signal });
|
||||
if (!res.ok) throw new Error(`loan methods failed (${res.status})`);
|
||||
return res.json() as Promise<StoreLoanMethodsResponse>;
|
||||
}
|
||||
|
||||
/** Browser-side calculate — POST .../store-loan-methods/calculate. */
|
||||
export async function fetchStoreLoanPlan(
|
||||
input: {
|
||||
methodId: string;
|
||||
totalValue: number;
|
||||
creditAmount: number;
|
||||
months: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoreLoanCalculateResponse> {
|
||||
const res = await fetch(`${base}/calculate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `calculate failed (${res.status})`;
|
||||
try {
|
||||
const body = (await res.json()) as { message?: string };
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json() as Promise<StoreLoanCalculateResponse>;
|
||||
}
|
||||
|
||||
export function clampCreditAmount(
|
||||
value: number,
|
||||
totalValue: number,
|
||||
minAmount: number,
|
||||
maxAmount: number,
|
||||
): number {
|
||||
const upper = Math.min(Math.round(maxAmount), Math.round(totalValue));
|
||||
const lower = Math.min(Math.round(minAmount), upper);
|
||||
return Math.min(upper, Math.max(lower, Math.round(value)));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// IRANYekan *fanum* maps Latin digit codepoints (0-9) to Persian glyphs.
|
||||
// Montserrat (`.en`) keeps real Latin digits — so any Latin-letter run that
|
||||
// also carries model numbers (HP R5, 8GB, EF2126WM…) must render under `.en`.
|
||||
// Pure digit runs (e.g. "13" inside «آیفون 13 پرو») stay on the FA font and
|
||||
// correctly show as Persian digits.
|
||||
const EN_RUN = /[A-Za-z0-9][A-Za-z0-9\s\-_|./+#%]*/g;
|
||||
|
||||
export type TextSegment = { type: "fa" | "en"; text: string };
|
||||
|
||||
export function splitMixedText(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(EN_RUN.source, "g");
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
let start = match.index;
|
||||
let end = start + match[0].length;
|
||||
while (end > start && /\s/.test(text[end - 1]!)) end--;
|
||||
const chunk = text.slice(start, end);
|
||||
if (!/[A-Za-z]/.test(chunk)) continue;
|
||||
|
||||
if (start > cursor) segments.push({ type: "fa", text: text.slice(cursor, start) });
|
||||
segments.push({ type: "en", text: chunk });
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if (cursor < text.length) segments.push({ type: "fa", text: text.slice(cursor) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Wrap Latin runs in a plain string as `.en` spans (text is assumed already HTML-safe). */
|
||||
export function wrapLatinRuns(text: string): string {
|
||||
return splitMixedText(text)
|
||||
.map((seg) =>
|
||||
seg.type === "en" ? `<span class="en" dir="ltr" lang="en">${seg.text}</span>` : seg.text,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Walk HTML text nodes (content between tags) and wrap Latin runs. */
|
||||
export function wrapLatinInHtml(html: string): string {
|
||||
return html.replace(/(^|>)([^<]+)/g, (_, prefix: string, text: string) => prefix + wrapLatinRuns(text));
|
||||
}
|
||||
@@ -315,3 +315,48 @@ export interface AuthUser {
|
||||
// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem
|
||||
// type here on purpose, calling /businesses/{id}/cart* from this site is
|
||||
// against the documented contract.
|
||||
|
||||
// GET /tenants/{domain}/store-loan-methods — hide UI when enabled:false / empty.
|
||||
export interface StoreLoanMethod {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
imageAspectRatio: string;
|
||||
minShoppingAmount: number;
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
interest: number;
|
||||
returnMonthsMin: number;
|
||||
returnMonthsMax: number;
|
||||
returnMonthsStep: number;
|
||||
returnMonthOptions: number[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface StoreLoanMethodsResponse {
|
||||
enabled: boolean;
|
||||
items: StoreLoanMethod[];
|
||||
}
|
||||
|
||||
export interface StoreLoanInstallment {
|
||||
index: number;
|
||||
dueDate: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface StoreLoanPlan {
|
||||
totalValue: number;
|
||||
creditAmount: number;
|
||||
cashPayment: number;
|
||||
interestFee: number;
|
||||
totalRepay: number;
|
||||
months: number;
|
||||
installments: StoreLoanInstallment[];
|
||||
}
|
||||
|
||||
export interface StoreLoanCalculateResponse {
|
||||
method: StoreLoanMethod;
|
||||
plan: StoreLoanPlan;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user