diff --git a/AGENTS.md b/AGENTS.md index 4597ea4..9d588fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. + + + +# 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. + + diff --git a/src/app/globals.css b/src/app/globals.css index 74b5e79..8a50cce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/app/products/[id]/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx index 663f853..7abd420 100644 --- a/src/app/products/[id]/[slug]/page.tsx +++ b/src/app/products/[id]/[slug]/page.tsx @@ -77,10 +77,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ ]} /> -
- - -
+ } + /> -
- - -
+ } + /> 0 && } {item.href ? ( - {item.label} + {item.label} ) : ( - {item.label} + + {item.label} + )} ))} diff --git a/src/components/LoanCalculatorModal.tsx b/src/components/LoanCalculatorModal.tsx new file mode 100644 index 0000000..fa8d11f --- /dev/null +++ b/src/components/LoanCalculatorModal.tsx @@ -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([]); + const [enabled, setEnabled] = useState(true); + const [loadingMethods, setLoadingMethods] = useState(false); + const [methodId, setMethodId] = useState(null); + const [months, setMonths] = useState(0); + const [creditAmount, setCreditAmount] = useState(0); + const [plan, setPlan] = useState(null); + const [calculating, setCalculating] = useState(false); + const [error, setError] = useState(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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="loan-calc-title" + > +
+
+

+ + محاسبه اقساط +

+

+ این محصول را اقساطی بخرید +

+

+ مبلغ کالا: {priceLabel} + {totalValue !== null && تومان} +

+
+ +
+ +
+ {loadingMethods && ( +

در حال دریافت روش‌های اقساط…

+ )} + + {!loadingMethods && (!enabled || methods.length === 0) && ( +

+ در حال حاضر فروش اقساطی برای این فروشگاه فعال نیست. +

+ )} + + {!loadingMethods && needsMethodPick && ( +
+

انتخاب طرح اقساط

+
+ {methods.map((m) => ( + + ))} +
+
+ )} + + {!loadingMethods && method && ( + <> + {methods.length > 1 && ( + + )} + + {methods.length === 1 && ( +

+ {method.nameFa || method.nameEn} + {method.description ? ` — ${method.description}` : ""} +

+ )} + +
+
+

مدت بازپرداخت

+ + {toFaDigits(months)} ماه + +
+
+ {monthOptions.map((m) => ( + + ))} +
+

+ سود سالانه {toFaDigits(method.interest)}٪ · اعتبار از{" "} + {formatPrice(method.minAmount)} تا {formatPrice(method.maxAmount)} تومان +

+
+ + {totalValue !== null && totalValue > method.minAmount && ( +
+
+

مبلغ اعتبار (قسطی)

+ + {formatPrice(creditAmount)} + تومان + +
+ + setCreditAmount( + clampCreditAmount( + Number(e.target.value), + totalValue, + method.minAmount, + method.maxAmount, + ), + ) + } + className="w-full accent-[var(--color-brand)]" + /> +
+ نقدی: {formatPrice(Math.max(0, totalValue - creditAmount))} تومان + حداکثر اعتبار +
+
+ )} + + {error && ( +

+ {error} +

+ )} + + {calculating && !plan && ( +

در حال محاسبه…

+ )} + + {plan && !error && ( +
+

جزئیات وام

+
+ + + + +
+ +
+
+ جدول اقساط + {toFaDigits(plan.installments.length)} قسط +
+
    + {plan.installments.map((item) => ( +
  • + + + {toFaDigits(item.index)} + + {formatDueDate(item.dueDate)} + + + {formatPrice(item.amount)} + تومان + +
  • + ))} +
+
+
+ )} + + )} +
+ + {plan && !error && ( +
+

+ + برای نهایی کردن خرید اقساطی با فروشگاه هماهنگ کنید +

+
+ )} +
+
, + document.body, + ); +} + +function SummaryCard({ + label, + value, + emphasize, +}: { + label: string; + value: number; + emphasize?: boolean; +}) { + return ( +
+

{label}

+

+ {formatPrice(value)} + + تومان + +

+
+ ); +} diff --git a/src/components/MixedText.tsx b/src/components/MixedText.tsx new file mode 100644 index 0000000..bb726c4 --- /dev/null +++ b/src/components/MixedText.tsx @@ -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" ? ( + + {seg.text} + + ) : ( + {seg.text} + ), + ); + + if (className) return {nodes}; + return <>{nodes}; +} diff --git a/src/components/ProductBuyPanel.tsx b/src/components/ProductBuyPanel.tsx index 7911730..a7d3d7d 100644 --- a/src/components/ProductBuyPanel.tsx +++ b/src/components/ProductBuyPanel.tsx @@ -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 = (
-

{title}

+

+ {title} +

{product.brand &&

برند: {product.brand}

} {product.summary && (

توضیح کوتاه:

-

{product.summary}

+

+ {product.summary} +

)} @@ -246,4 +257,45 @@ export default function ProductBuyPanel({
); + + const installmentRow = ( + + ); + + return ( + <> + {gallery ? ( +
+
+ {gallery} + {buyColumn} +
+ {installmentRow} +
+ ) : ( +
+ {buyColumn} + {installmentRow} +
+ )} + + setLoanOpen(false)} /> + + ); } diff --git a/src/components/ProductInfoTabs.tsx b/src/components/ProductInfoTabs.tsx index c291cfc..487d7ee 100644 --- a/src/components/ProductInfoTabs.tsx +++ b/src/components/ProductInfoTabs.tsx @@ -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({
{tab === "description" && (
-

معرفی {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; +}