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
|
`origin/main` unpushed, and the business reported bugs that were already
|
||||||
fixed locally — they were looking at the old deployed code. Check
|
fixed locally — they were looking at the old deployed code. Check
|
||||||
`git status` for "ahead of origin" before telling anyone a fix is live.
|
`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;
|
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 {
|
.en {
|
||||||
font-family: var(--font-en), var(--font-fa), sans-serif;
|
font-family: var(--font-en), var(--font-fa), sans-serif;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Swiper theming: its own CSS reads these custom properties when drawing
|
/* 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]">
|
<ProductBuyPanel
|
||||||
<ProductGallery images={images} title={title} />
|
product={product}
|
||||||
<ProductBuyPanel product={product} variants={variants} />
|
variants={variants}
|
||||||
</div>
|
gallery={<ProductGallery images={images} title={title} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProductInfoTabs
|
<ProductInfoTabs
|
||||||
title={title}
|
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]">
|
<ProductBuyPanel
|
||||||
<ProductGallery images={images} title={title} />
|
product={product}
|
||||||
<ProductBuyPanel product={product} variants={variants} initialVariantId={variant.id} />
|
variants={variants}
|
||||||
</div>
|
initialVariantId={variant.id}
|
||||||
|
gallery={<ProductGallery images={images} title={title} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProductInfoTabs
|
<ProductInfoTabs
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import MixedText from "./MixedText";
|
||||||
import { ChevronLeft } from "./icons";
|
import { ChevronLeft } from "./icons";
|
||||||
|
|
||||||
export interface Crumb {
|
export interface Crumb {
|
||||||
@@ -14,10 +15,12 @@ export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
|||||||
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
|
{i > 0 && <ChevronLeft className="size-3.5 opacity-60" />}
|
||||||
{item.href ? (
|
{item.href ? (
|
||||||
<Link href={item.href} className="transition hover:text-brand">
|
<Link href={item.href} className="transition hover:text-brand">
|
||||||
{item.label}
|
<MixedText>{item.label}</MixedText>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-ink-soft">{item.label}</span>
|
<span className="text-ink-soft">
|
||||||
|
<MixedText>{item.label}</MixedText>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
import { CartIcon, CheckIcon, ChevronLeft, RotateIcon, ShieldIcon, TruckIcon, WalletIcon } from "./icons";
|
||||||
|
import LoanCalculatorModal from "./LoanCalculatorModal";
|
||||||
|
import MixedText from "./MixedText";
|
||||||
import { useCart } from "./providers/CartProvider";
|
import { useCart } from "./providers/CartProvider";
|
||||||
import { useToast } from "./providers/ToastProvider";
|
import { useToast } from "./providers/ToastProvider";
|
||||||
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
|
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
|
||||||
@@ -25,17 +27,22 @@ export default function ProductBuyPanel({
|
|||||||
product,
|
product,
|
||||||
variants = [],
|
variants = [],
|
||||||
initialVariantId,
|
initialVariantId,
|
||||||
|
gallery,
|
||||||
}: {
|
}: {
|
||||||
product: ApiProduct;
|
product: ApiProduct;
|
||||||
variants?: StoreSpecialVariant[];
|
variants?: StoreSpecialVariant[];
|
||||||
/** Preselect a specific variant — used when entering via its own
|
/** Preselect a specific variant — used when entering via its own
|
||||||
* /store-items/{id} URL rather than the product page. */
|
* /store-items/{id} URL rather than the product page. */
|
||||||
initialVariantId?: string;
|
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 inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
|
||||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
|
const [loanOpen, setLoanOpen] = useState(false);
|
||||||
const { addItem } = useCart();
|
const { addItem } = useCart();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
||||||
@@ -85,16 +92,20 @@ export default function ProductBuyPanel({
|
|||||||
setTimeout(() => setAdded(false), 1800);
|
setTimeout(() => setAdded(false), 1800);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const buyColumn = (
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_300px] md:gap-10">
|
||||||
<div>
|
<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.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
|
||||||
|
|
||||||
{product.summary && (
|
{product.summary && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="mb-1.5 text-sm font-bold text-ink">توضیح کوتاه:</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -246,4 +257,45 @@ export default function ProductBuyPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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 { useState } from "react";
|
||||||
import CommentSection from "./CommentSection";
|
import CommentSection from "./CommentSection";
|
||||||
|
import MixedText from "./MixedText";
|
||||||
|
import { wrapLatinInHtml } from "@/lib/mixedText";
|
||||||
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
|
import type { AuthUser, EntityComment, SpecRow } from "@/lib/types";
|
||||||
|
|
||||||
type Tab = "description" | "technical" | "reviews";
|
type Tab = "description" | "technical" | "reviews";
|
||||||
@@ -49,11 +51,13 @@ export default function ProductInfoTabs({
|
|||||||
<div className="pt-8">
|
<div className="pt-8">
|
||||||
{tab === "description" && (
|
{tab === "description" && (
|
||||||
<div>
|
<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 ? (
|
{descriptionHtml ? (
|
||||||
<div
|
<div
|
||||||
className="rich-text text-sm leading-8 text-body"
|
className="rich-text text-sm leading-8 text-body"
|
||||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
dangerouslySetInnerHTML={{ __html: wrapLatinInHtml(descriptionHtml) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
|
<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">
|
<dl className="divide-y divide-line overflow-hidden rounded-xl border border-line">
|
||||||
{specRows.map((row) => (
|
{specRows.map((row) => (
|
||||||
<div key={row.key} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
|
<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>
|
<dt className="text-body">
|
||||||
<dd className="font-bold text-ink">{row.value}</dd>
|
<MixedText>{row.label}</MixedText>
|
||||||
|
</dt>
|
||||||
|
<dd className="font-bold text-ink">
|
||||||
|
<MixedText>{row.value}</MixedText>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { SearchIcon } from "./icons";
|
import { SearchIcon } from "./icons";
|
||||||
|
import MixedText from "./MixedText";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { productHref } from "@/lib/slug";
|
import { productHref } from "@/lib/slug";
|
||||||
import type { ApiProduct } from "@/lib/types";
|
import type { ApiProduct } from "@/lib/types";
|
||||||
@@ -110,7 +111,9 @@ export default function SearchBox() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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>
|
</span>
|
||||||
{p.store.minPrice !== null && (
|
{p.store.minPrice !== null && (
|
||||||
<span className="shrink-0 text-[12px] font-bold text-ink">
|
<span className="shrink-0 text-[12px] font-bold text-ink">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import MixedText from "@/components/MixedText";
|
||||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||||
import type { ProductCardData } from "@/lib/types";
|
import type { ProductCardData } from "@/lib/types";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export default function ProductCard({ product }: { product: ProductCardData }) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
|
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
|
||||||
{title}
|
<MixedText>{title}</MixedText>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* The old-price line always occupies its row — rendered empty when there
|
{/* The old-price line always occupies its row — rendered empty when there
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type {
|
|||||||
StaticImageSlot,
|
StaticImageSlot,
|
||||||
StoreItem,
|
StoreItem,
|
||||||
StoreItemVariants,
|
StoreItemVariants,
|
||||||
|
StoreLoanCalculateResponse,
|
||||||
|
StoreLoanMethodsResponse,
|
||||||
StoreSpecial,
|
StoreSpecial,
|
||||||
Tenant,
|
Tenant,
|
||||||
TechnicalInfoForm,
|
TechnicalInfoForm,
|
||||||
@@ -224,6 +226,21 @@ export function submitContactForm(
|
|||||||
return apiPost(t("/contact-submissions"), data);
|
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
|
// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and
|
||||||
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
|
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
|
||||||
// customer-dashboard-only. The mini-cart is local-only; see
|
// 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
|
// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem
|
||||||
// type here on purpose, calling /businesses/{id}/cart* from this site is
|
// type here on purpose, calling /businesses/{id}/cart* from this site is
|
||||||
// against the documented contract.
|
// 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