-
معرفی {title}
+
+ معرفی {title}
+
{descriptionHtml ? (
) : (
توضیحی برای این محصول ثبت نشده است.
@@ -67,8 +71,12 @@ export default function ProductInfoTabs({
{specRows.map((row) => (
-
- {row.label}
- - {row.value}
+ -
+ {row.label}
+
+ -
+ {row.value}
+
))}
diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx
index 2f48318..054aa9f 100644
--- a/src/components/SearchBox.tsx
+++ b/src/components/SearchBox.tsx
@@ -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() {
)}
- {p.nameFa || p.title}
+
+ {p.nameFa || p.title}
+
{p.store.minPrice !== null && (
diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx
index 6ed8665..3e40ddf 100644
--- a/src/components/ui/ProductCard.tsx
+++ b/src/components/ui/ProductCard.tsx
@@ -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 }) {
- {title}
+ {title}
{/* The old-price line always occupies its row — rendered empty when there
diff --git a/src/lib/api.ts b/src/lib/api.ts
index b9510f4..2ce0a91 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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
{
+ return apiGet(t("/store-loan-methods"));
+}
+
+export function calculateStoreLoan(input: {
+ methodId: string;
+ totalValue: number;
+ creditAmount: number;
+ months: number;
+}): Promise {
+ 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
diff --git a/src/lib/loanMethods.ts b/src/lib/loanMethods.ts
new file mode 100644
index 0000000..08dfc2b
--- /dev/null
+++ b/src/lib/loanMethods.ts
@@ -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 {
+ const res = await fetch(base, { signal });
+ if (!res.ok) throw new Error(`loan methods failed (${res.status})`);
+ return res.json() as Promise;
+}
+
+/** Browser-side calculate — POST .../store-loan-methods/calculate. */
+export async function fetchStoreLoanPlan(
+ input: {
+ methodId: string;
+ totalValue: number;
+ creditAmount: number;
+ months: number;
+ },
+ signal?: AbortSignal,
+): Promise {
+ 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;
+}
+
+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)));
+}
diff --git a/src/lib/mixedText.ts b/src/lib/mixedText.ts
new file mode 100644
index 0000000..fd29964
--- /dev/null
+++ b/src/lib/mixedText.ts
@@ -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" ? `${seg.text}` : 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));
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b190ee7..ba52146 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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;
+}