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:
Alireza Hassani
2026-09-25 00:21:40 +03:30
co-authored by Cursor
parent 7b8b5fd723
commit e625354dc9
15 changed files with 717 additions and 22 deletions
+10
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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}
+6 -4
View File
@@ -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}
+5 -2
View File
@@ -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>
))}
+431
View File
@@ -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>
);
}
+23
View File
@@ -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}</>;
}
+57 -5
View File
@@ -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)} />
</>
);
}
+12 -4
View File
@@ -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 -1
View File
@@ -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">
+2 -1
View File
@@ -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
+17
View File
@@ -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
+53
View File
@@ -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)));
}
+44
View File
@@ -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));
}
+45
View File
@@ -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;
}