diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index d6e4561..8fb4487 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -37,6 +37,8 @@ import { OrdersPage } from './pages/OrdersPage' import { LoginPage } from './pages/LoginPage' import { ShoppingCardsPage } from './pages/ShoppingCardsPage' import { StoreSettingsPage } from './pages/StoreSettingsPage' +import { StoreInstallmentsPage } from './pages/StoreInstallmentsPage' +import { AddStoreInstallmentPage } from './pages/AddStoreInstallmentPage' import { BlogPage } from './pages/BlogPage' import { BlogListPage } from './pages/BlogListPage' import { AddNewBlogPage } from './pages/AddNewBlogPage' @@ -110,6 +112,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/BrandModal.tsx b/apps/business/src/components/BrandModal.tsx index 24dd8cd..b20d72e 100644 --- a/apps/business/src/components/BrandModal.tsx +++ b/apps/business/src/components/BrandModal.tsx @@ -115,6 +115,7 @@ export function BrandModal({ onChange={setImage} outputFormat="png" accept="image/png" + minZoom={0.4} uploadLabel={t('brands.modal.uploadLogo')} hint={t('brands.modal.logoHint')} changeLabel={t('brands.modal.changeLogo')} diff --git a/apps/business/src/components/DailyActivityChart.module.css b/apps/business/src/components/DailyActivityChart.module.css index c97ab3b..8ff400c 100644 --- a/apps/business/src/components/DailyActivityChart.module.css +++ b/apps/business/src/components/DailyActivityChart.module.css @@ -226,14 +226,15 @@ border-radius: 6px; box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18); z-index: 10000; + unicode-bidi: isolate; } .bar { position: relative; flex: none; - width: 80%; - max-width: 14px; - min-width: 8px; + width: 40%; + max-width: 7px; + min-width: 4px; border-radius: 3px 3px 1px 1px; transition: height 0.3s ease; cursor: default; diff --git a/apps/business/src/components/DailyActivityChart.tsx b/apps/business/src/components/DailyActivityChart.tsx index dc87b15..ef76b76 100644 --- a/apps/business/src/components/DailyActivityChart.tsx +++ b/apps/business/src/components/DailyActivityChart.tsx @@ -43,15 +43,19 @@ function formatDayLabel(dateKey: string, locale: string): string { }) } -/** Tooltip label with month context (e.g. ۶ شهریور / Sep 6). */ +/** Tooltip label with month context (e.g. ۲ مهر / Sep 6). */ function formatDayTooltip(dateKey: string, locale: string): string { return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { day: 'numeric', month: 'short', - numberingSystem: 'latn', + ...(locale === 'fa' ? {} : { numberingSystem: 'latn' as const }), }) } +function formatTooltipCount(value: number, locale: string): string { + return value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US') +} + /** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */ function formatRangeDate(dateKey: string, locale: string): string { return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { @@ -236,12 +240,12 @@ export function DailyActivityChart({ const primaryTip = t(primaryBarTitleKey, { day: item.label, month: item.label, - count: item.primary, + count: formatTooltipCount(item.primary, locale), }) const secondaryTip = t(secondaryBarTitleKey, { day: item.label, month: item.label, - count: item.secondary, + count: formatTooltipCount(item.secondary, locale), }) return ( @@ -254,7 +258,13 @@ export function DailyActivityChart({ height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0), }} > - {primaryTip} + + {primaryTip} + {showSecondary ? ( @@ -265,7 +275,13 @@ export function DailyActivityChart({ height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0), }} > - {secondaryTip} + + {secondaryTip} + ) : null} @@ -289,11 +305,11 @@ export function DailyActivityChart({ const tooltipDay = formatDayTooltip(item.date, locale) const primaryTip = t(primaryBarTitleKey, { day: tooltipDay, - count: item.count, + count: formatTooltipCount(item.count, locale), }) const secondaryTip = t(secondaryBarTitleKey, { day: tooltipDay, - count: secondaryCount, + count: formatTooltipCount(secondaryCount, locale), }) return ( @@ -306,7 +322,13 @@ export function DailyActivityChart({ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0), }} > - {primaryTip} + + {primaryTip} + {showSecondary ? ( @@ -317,7 +339,13 @@ export function DailyActivityChart({ height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0), }} > - {secondaryTip} + + {secondaryTip} + ) : null} diff --git a/apps/business/src/components/ImageCropper.module.css b/apps/business/src/components/ImageCropper.module.css index d06463f..3fb7f0e 100644 --- a/apps/business/src/components/ImageCropper.module.css +++ b/apps/business/src/components/ImageCropper.module.css @@ -98,6 +98,16 @@ background: #1e293b; } +.cropAreaTransparent { + background: + linear-gradient(45deg, #e2e8f0 25%, transparent 25%), + linear-gradient(-45deg, #e2e8f0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e2e8f0 75%), + linear-gradient(-45deg, transparent 75%, #e2e8f0 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; +} + .cropControls { padding: 14px 16px; display: flex; diff --git a/apps/business/src/components/ImageCropper.tsx b/apps/business/src/components/ImageCropper.tsx index e285b0c..345aabe 100644 --- a/apps/business/src/components/ImageCropper.tsx +++ b/apps/business/src/components/ImageCropper.tsx @@ -10,6 +10,10 @@ interface ImageCropperProps { onChange: (value: string | null) => void /** Crop aspect ratio. Defaults to square (1). */ aspect?: number + /** + * Output format. When omitted, follows the uploaded file + * (PNG stays PNG with transparent padding; other images become JPEG with white padding). + */ outputFormat?: 'jpeg' | 'png' /** Fill color for JPEG and for crop areas outside the image. */ fillColor?: string @@ -24,13 +28,24 @@ interface ImageCropperProps { compact?: boolean } +function isPngSource(file: File): boolean { + return ( + file.type === 'image/png' || + file.name.toLowerCase().endsWith('.png') + ) +} + +function isPngDataUrl(src: string): boolean { + return /^data:image\/png/i.test(src) +} + export function ImageCropper({ value, onChange, aspect = 1, - outputFormat = 'jpeg', + outputFormat, fillColor, - minZoom = 1, + minZoom = 0.4, maxZoom = 3, accept = 'image/*', uploadLabel, @@ -42,13 +57,18 @@ export function ImageCropper({ const resolvedUploadLabel = uploadLabel ?? t('products.form.thumbnailUpload') const resolvedHint = hint ?? t('products.form.thumbnailHint') const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange') - const resolvedFillColor = fillColor ?? (outputFormat === 'jpeg' ? '#ffffff' : undefined) - const cropSurfaceColor = resolvedFillColor ?? '#1e293b' const [imageSrc, setImageSrc] = useState(null) + const [sourceIsPng, setSourceIsPng] = useState(false) const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [croppedArea, setCroppedArea] = useState(null) + const resolvedFormat: 'jpeg' | 'png' = + outputFormat ?? (sourceIsPng ? 'png' : 'jpeg') + const resolvedFillColor = + fillColor ?? (resolvedFormat === 'jpeg' ? '#ffffff' : undefined) + const useTransparentPad = resolvedFormat === 'png' && !resolvedFillColor + useEffect(() => { setCrop({ x: 0, y: 0 }) setZoom(1) @@ -62,6 +82,8 @@ export function ImageCropper({ function handleFile(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return + const png = isPngSource(file) + setSourceIsPng(png) const reader = new FileReader() reader.onload = () => { setImageSrc(reader.result as string) @@ -75,12 +97,10 @@ export function ImageCropper({ async function applyCrop() { if (!imageSrc || !croppedArea) return - const cropped = await getCroppedImage( - imageSrc, - croppedArea, - outputFormat, - resolvedFillColor, - ) + const format: 'jpeg' | 'png' = + outputFormat ?? (isPngDataUrl(imageSrc) || sourceIsPng ? 'png' : 'jpeg') + const fill = fillColor ?? (format === 'jpeg' ? '#ffffff' : undefined) + const cropped = await getCroppedImage(imageSrc, croppedArea, format, fill) onChange(cropped) setImageSrc(null) setZoom(1) @@ -116,6 +136,10 @@ export function ImageCropper({ alignSelf: 'center', } + const cropSurfaceStyle: React.CSSProperties = useTransparentPad + ? { ...frameStyle } + : { ...frameStyle, background: resolvedFillColor ?? '#1e293b' } + return (
{value && !imageSrc && ( @@ -144,8 +168,8 @@ export function ImageCropper({ {imageSrc && (
= 1} style={{ - containerStyle: { background: cropSurfaceColor }, + containerStyle: useTransparentPad + ? { background: 'transparent' } + : { background: resolvedFillColor ?? '#1e293b' }, }} />
diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index 4eb395a..bea9c98 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -154,6 +154,14 @@ export function Sidebar({ open = false }: SidebarProps) { { labelKey: 'nav.store.orders', to: '/store/orders' }, { labelKey: 'nav.store.shipping', to: '/store/shipping' }, { labelKey: 'nav.store.cards', to: '/store/cards' }, + ...(hasBusinessModule(enabledModules, 'store_installments') + ? [ + { + labelKey: 'nav.store.installments' as BusinessMessageKey, + to: '/store/installments', + }, + ] + : []), { labelKey: 'nav.store.settings', to: '/store/settings' }, ], }, diff --git a/apps/business/src/components/StoreLoanMethodRow.module.css b/apps/business/src/components/StoreLoanMethodRow.module.css new file mode 100644 index 0000000..e38e6ed --- /dev/null +++ b/apps/business/src/components/StoreLoanMethodRow.module.css @@ -0,0 +1,211 @@ +.row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + transition: background 0.2s; +} + +.row:last-child { + border-bottom: none; +} + +.row:hover { + background: rgba(var(--primary-rgb) / 0.04); +} + +.logo { + height: 44px; + width: auto; + max-width: 88px; + flex-shrink: 0; + object-fit: contain; + object-position: center; + border-radius: 8px; + background: + linear-gradient(45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%), + linear-gradient(-45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%), + linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%); + background-size: 12px 12px; + background-position: 0 0, 0 6px, 6px -6px, -6px 0; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.titleBlock { + flex: 0 1 180px; + min-width: 120px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.names { + display: flex; + align-items: baseline; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} + +.namePrimary { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.nameSecondary { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.nameEn { + direction: ltr; + unicode-bidi: isolate; + font-family: var(--font-en), var(--font-ui); +} + +.description { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metrics { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-height: 32px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.chipLabel { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + +.chipValue { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 3px 5px; + min-width: 0; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; + direction: ltr; + unicode-bidi: isolate; + font-family: var(--font-en), var(--font-ui); +} + +.chipNumber { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; + line-height: 1.2; +} + +.chipSep { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.chipUnit { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); +} + +.chipHint { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + +.controls { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-inline-start: auto; +} + +.controlBtn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: var(--text-secondary); + transition: background 0.15s, color 0.15s; +} + +.controlBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.useBtn { + color: var(--primary); +} + +.useBtn:hover { + background: rgba(var(--primary-rgb) / 0.14); + color: var(--primary); +} + +.danger:hover { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; +} + +@media (max-width: 900px) { + .row { + flex-wrap: wrap; + } + + .titleBlock { + flex: 1 1 160px; + } + + .metrics { + flex: 1 1 100%; + order: 3; + } + + .controls { + margin-inline-start: 0; + } +} diff --git a/apps/business/src/components/StoreLoanMethodRow.tsx b/apps/business/src/components/StoreLoanMethodRow.tsx new file mode 100644 index 0000000..d7881cc --- /dev/null +++ b/apps/business/src/components/StoreLoanMethodRow.tsx @@ -0,0 +1,150 @@ +import { Calculator, Pencil, Trash2 } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import type { StoreLoanMethod } from '../types/storeLoanMethod' +import { useT } from '../i18n/useT' +import { Tooltip } from './Tooltip' +import styles from './StoreLoanMethodRow.module.css' + +interface StoreLoanMethodRowProps { + method: StoreLoanMethod + onUse: (id: string) => void + onEdit: (id: string) => void + onRemove: (id: string) => void +} + +function formatMoney(value: number) { + return Math.round(value).toLocaleString('en-US') +} + +function formatInterest(value: number) { + return Number.isInteger(value) ? String(value) : value.toLocaleString('en-US') +} + +export function StoreLoanMethodRow({ + method, + onUse, + onEdit, + onRemove, +}: StoreLoanMethodRowProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa + const secondaryName = isFa + ? method.nameFa + ? method.nameEn + : '' + : method.nameEn + ? method.nameFa + : '' + + return ( +
+ {method.imageUrl ? ( + + ) : null} + +
+
+ + {primaryName} + + {secondaryName ? ( + + {secondaryName} + + ) : null} +
+ {method.description ? ( +

{method.description}

+ ) : null} +
+ +
+
+ + {t('store.installments.row.minShopping')} + + + + {formatMoney(method.minShoppingAmount)} + + +
+ +
+ + {t('store.installments.row.creditAmount')} + + + {formatMoney(method.minAmount)} + – + {formatMoney(method.maxAmount)} + +
+ +
+ + {t('store.installments.row.interest')} + + + {formatInterest(method.interest)} + % + +
+ +
+ + {t('store.installments.row.months')} + + + {method.returnMonthsMin} + – + {method.returnMonthsMax} + + {t('store.installments.row.monthsUnit')} + + + + {t('store.installments.row.monthsStep', { + step: method.returnMonthsStep, + })} + +
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/apps/business/src/components/StoreLoanUseModal.module.css b/apps/business/src/components/StoreLoanUseModal.module.css new file mode 100644 index 0000000..97622a4 --- /dev/null +++ b/apps/business/src/components/StoreLoanUseModal.module.css @@ -0,0 +1,402 @@ +.overlay { + position: fixed; + inset: 0; + background: var(--modal-overlay-bg); + backdrop-filter: blur(var(--modal-overlay-blur)); + -webkit-backdrop-filter: blur(var(--modal-overlay-blur)); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + padding: 12px; +} + +.overlayIn { + animation: overlayIn 0.22s ease forwards; +} + +.overlayOut { + animation: overlayOut 0.22s ease forwards; +} + +.modal { + width: 100%; + max-width: 720px; + max-height: calc(100vh - 24px); + display: flex; + flex-direction: column; + background: var(--elevated-surface, var(--surface)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16); + overflow: hidden; +} + +.modalIn { + animation: modalIn 0.22s ease forwards; +} + +.modalOut { + animation: modalOut 0.22s ease forwards; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px 16px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + flex-shrink: 0; +} + +.headerText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.headerBadge { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--primary); + background: rgba(var(--primary-rgb) / 0.12); +} + +.title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.subtitle { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.closeBtn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: var(--text-muted); + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +} + +.closeBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.body { + padding: 12px 16px 16px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(148, 163, 184, 0.06); + border: 1px solid rgba(148, 163, 184, 0.14); +} + +.sectionTitle { + margin: 0; + font-size: 12px; + font-weight: 700; + color: var(--text-primary); +} + +.planGrid { + display: grid; + grid-template-columns: 0.9fr 1.5fr; + gap: 10px; + align-items: start; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.field label { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); +} + +.field input:not([type='range']), +.field select { + width: 100%; + height: 34px; + padding: 0 10px; + font-size: 13px; + color: var(--text); + background: var(--surface); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-sm); + outline: none; +} + +.field select { + cursor: pointer; +} + +.field input:not([type='range']):focus, +.field select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.field input:disabled, +.field select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.sliderHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sliderValue { + font-size: 12px; + font-weight: 700; + color: var(--primary); + font-variant-numeric: tabular-nums; + direction: ltr; + unicode-bidi: isolate; +} + +.slider { + width: 100%; + height: 28px; + margin: 0; + padding: 0; + accent-color: var(--primary); + cursor: pointer; + background: transparent; + border: none; + direction: ltr; +} + +.slider:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.sliderMeta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 10px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + direction: ltr; +} + +.sliderMeta > span:nth-child(2) { + direction: inherit; + text-align: center; + flex: 1; +} + +.hint { + margin: 0; + font-size: 10px; + line-height: 1.35; + color: var(--text-muted); +} + +.warning { + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + color: #b45309; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.28); +} + +.emptyResult { + margin: 0; + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.summaryCard { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 10px; + background: var(--surface); + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.summaryCardWide { + grid-column: 1 / -1; +} + +.summaryLabel { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); +} + +.summaryValue { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; + direction: ltr; + unicode-bidi: isolate; + font-family: var(--font-en), var(--font-ui); + letter-spacing: -0.02em; +} + +.schedule { + display: flex; + flex-direction: column; + gap: 6px; +} + +.scheduleHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); +} + +.scheduleList { + display: flex; + flex-direction: column; + max-height: 180px; + overflow: auto; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: var(--surface); +} + +.scheduleRow { + display: grid; + grid-template-columns: 72px 1fr auto; + gap: 8px; + align-items: center; + padding: 7px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); +} + +.scheduleRow:last-child { + border-bottom: none; +} + +.scheduleIndex { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} + +.scheduleDate { + font-size: 13px; + color: var(--text-secondary); +} + +.scheduleAmount { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + direction: ltr; + unicode-bidi: isolate; + font-family: var(--font-en), var(--font-ui); +} + +@keyframes overlayIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes overlayOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes modalIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modalOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} + +@media (max-width: 700px) { + .planGrid, + .summaryGrid { + grid-template-columns: 1fr; + } + + .scheduleRow { + grid-template-columns: 1fr auto; + } + + .scheduleIndex { + grid-column: 1 / -1; + } +} diff --git a/apps/business/src/components/StoreLoanUseModal.tsx b/apps/business/src/components/StoreLoanUseModal.tsx new file mode 100644 index 0000000..4778c64 --- /dev/null +++ b/apps/business/src/components/StoreLoanUseModal.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Calculator, X } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import { useT } from '../i18n/useT' +import type { StoreLoanMethod } from '../types/storeLoanMethod' +import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' +import { + calculateStoreLoanPlan, + clampCreditAmount, + listReturnMonthOptions, +} from '../utils/storeLoanCalculator' +import styles from './StoreLoanUseModal.module.css' + +interface StoreLoanUseModalProps { + open: boolean + method: StoreLoanMethod | null + onClose: () => void +} + +const ANIMATION_MS = 220 + +function formatMoney(value: number) { + return Math.round(value).toLocaleString('en-US') +} + +function formatDate(value: Date, locale: 'en' | 'fa') { + return value.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + }) +} + +export function StoreLoanUseModal({ open, method, onClose }: StoreLoanUseModalProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [totalValueInput, setTotalValueInput] = useState('') + const [creditInput, setCreditInput] = useState('') + const [months, setMonths] = useState(0) + + const monthOptions = useMemo(() => { + if (!method) return [] + return listReturnMonthOptions( + method.returnMonthsMin, + method.returnMonthsMax, + method.returnMonthsStep, + ) + }, [method]) + + useEffect(() => { + if (open && method) { + setMounted(true) + setClosing(false) + setTotalValueInput('') + setCreditInput('') + const options = listReturnMonthOptions( + method.returnMonthsMin, + method.returnMonthsMax, + method.returnMonthsStep, + ) + setMonths(options[0] ?? method.returnMonthsMin) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, method, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + const totalValue = parseIrtInput(totalValueInput) + const creditRaw = parseIrtInput(creditInput) + + const plan = useMemo(() => { + if (!method || totalValue === null || creditRaw === null) return null + const creditAmount = clampCreditAmount( + creditRaw, + totalValue, + method.minAmount, + method.maxAmount, + ) + return calculateStoreLoanPlan({ + totalValue, + creditAmount, + interestPercent: method.interest, + months, + }) + }, [method, totalValue, creditRaw, months]) + + const validationMessage = useMemo(() => { + if (!method) return '' + if (totalValue === null) return '' + if (totalValue < method.minShoppingAmount) { + return t('store.installments.use.errorMinShopping', { + amount: formatMoney(method.minShoppingAmount), + }) + } + if (creditRaw === null) return '' + if (creditRaw > totalValue) { + return t('store.installments.use.errorCreditOverTotal') + } + if (creditRaw < method.minAmount || creditRaw > method.maxAmount) { + return t('store.installments.use.errorCreditRange', { + min: formatMoney(method.minAmount), + max: formatMoney(method.maxAmount), + }) + } + return '' + }, [method, totalValue, creditRaw, t]) + + if (!mounted || !method) return null + + const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa + + function handleTotalChange(raw: string) { + const formatted = formatIrtInput(raw) + setTotalValueInput(formatted) + const nextTotal = parseIrtInput(formatted) + if (nextTotal === null || !method) return + const suggested = clampCreditAmount( + Math.min(nextTotal, method.maxAmount), + nextTotal, + method.minAmount, + method.maxAmount, + ) + if (!creditInput || creditRaw === null) { + setCreditInput(formatIrtInput(String(suggested))) + } else { + const clamped = clampCreditAmount( + creditRaw, + nextTotal, + method.minAmount, + method.maxAmount, + ) + setCreditInput(formatIrtInput(String(clamped))) + } + } + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="store-loan-use-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} + > +
+
+
+ + {t('store.installments.use.badge')} +
+

+ {primaryName} +

+

{t('store.installments.use.subtitle')}

+
+ +
+ +
+
+

+ {t('store.installments.use.section.value')} +

+
+ + handleTotalChange(e.target.value)} + placeholder={t('store.installments.use.totalValuePlaceholder')} + /> +

+ {t('store.installments.use.totalValueHint', { + amount: formatMoney(method.minShoppingAmount), + })} +

+
+
+ +
+

+ {t('store.installments.use.section.plan')} +

+
+
+ + setCreditInput(formatIrtInput(e.target.value))} + placeholder={t('store.installments.use.creditAmountPlaceholder')} + disabled={totalValue === null} + /> +

+ {t('store.installments.use.creditAmountHint', { + min: formatMoney(method.minAmount), + max: formatMoney(method.maxAmount), + })} +

+
+ +
+
+ + + {t('store.installments.use.monthsOption', { months })} + +
+ setMonths(Number(e.target.value))} + disabled={monthOptions.length === 0} + /> +
+ {method.returnMonthsMin} + + {t('store.installments.use.monthsHint', { + step: method.returnMonthsStep, + })} + + {method.returnMonthsMax} +
+
+
+
+ +
+

+ {t('store.installments.use.section.result')} +

+ + {validationMessage ? ( +
+ {validationMessage} +
+ ) : null} + + {!plan || validationMessage ? ( +

+ {t('store.installments.use.resultEmpty')} +

+ ) : ( + <> +
+
+ + {t('store.installments.use.result.totalValue')} + + + {formatMoney(plan.totalValue)} + +
+
+ + {t('store.installments.use.result.credit')} + + + {formatMoney(plan.creditAmount)} + +
+
+ + {t('store.installments.use.result.cash')} + + + {formatMoney(plan.cashPayment)} + +
+
+ + {t('store.installments.use.result.fees')} + + + {formatMoney(plan.interestFee)} + +
+
+ + {t('store.installments.use.result.totalRepay')} + + + {formatMoney(plan.totalRepay)} + +
+
+ +
+
+ {t('store.installments.use.schedule.title')} + + {t('store.installments.use.schedule.count', { + count: plan.installments.length, + })} + +
+
+ {plan.installments.map((item) => ( +
+ + {t('store.installments.use.schedule.item', { + index: item.index, + })} + + + {formatDate(item.dueDate, locale)} + + + {formatMoney(item.amount)} + +
+ ))} +
+
+ + )} +
+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 91cb260..dd4c198 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -32,6 +32,7 @@ const en = { 'nav.store.orders': 'My Orders', 'nav.store.shipping': 'Shipping Fees', 'nav.store.cards': 'Shopping Cards', + 'nav.store.installments': 'Installments & Facilities', 'nav.store.settings': 'Settings', 'nav.customers': 'Users', 'nav.customerProducts': 'Customer products', @@ -912,8 +913,111 @@ const en = { 'store.card.shipping.link': 'Manage shipping', 'store.card.cards.desc': 'Manage saved shopping cards and payment methods.', 'store.card.cards.link': 'View cards', + 'store.card.installments.desc': + 'Define installment and loan facilities available to customers.', + 'store.card.installments.link': 'Manage installments', 'store.card.settings.desc': 'Configure store preferences, pages and themes.', 'store.card.settings.link': 'View settings', + 'store.installments.page.subtitle': + 'Create loan and installment methods your store can offer customers.', + 'store.installments.loading': 'Loading installment methods…', + 'store.installments.empty': 'No installment methods yet. Add your first one.', + 'store.installments.add': 'Add installment method', + 'store.installments.errorLoad': 'Failed to load installment methods.', + 'store.installments.errorDelete': 'Failed to delete installment method.', + 'store.installments.deleteTitle': 'Delete installment method', + 'store.installments.deleteMessage': + 'Are you sure you want to delete "{name}"? This action cannot be undone.', + 'store.installments.toast.created': 'Installment method created.', + 'store.installments.toast.updated': 'Installment method updated.', + 'store.installments.toast.deleted': 'Installment method deleted.', + 'store.installments.tooltip.edit': 'Edit', + 'store.installments.tooltip.remove': 'Delete', + 'store.installments.tooltip.use': 'Use / calculate', + 'store.installments.row.summary': + 'Min shopping {minShopping} · Loan {minAmount}–{maxAmount} · Interest {interest}% · {monthsMin}–{monthsMax} months (step {monthsStep})', + 'store.installments.row.minShopping': 'Min shopping', + 'store.installments.row.creditAmount': 'Credit amount', + 'store.installments.row.interest': 'Interest', + 'store.installments.row.months': 'Return months', + 'store.installments.row.monthsUnit': 'mo', + 'store.installments.row.monthsStep': 'Step {step}', + 'store.installments.use.badge': 'Installment calculator', + 'store.installments.use.subtitle': + 'Enter the purchase value, choose credit and term, then review repayments.', + 'store.installments.use.section.value': '1. Purchase value', + 'store.installments.use.section.plan': '2. Credit & repayment term', + 'store.installments.use.section.result': '3. Payment plan', + 'store.installments.use.totalValue': 'Total value', + 'store.installments.use.totalValuePlaceholder': 'e.g. 10,000,000', + 'store.installments.use.totalValueHint': 'Minimum shopping for this method: {amount}', + 'store.installments.use.creditAmount': 'Credit amount', + 'store.installments.use.creditAmountPlaceholder': 'Amount financed', + 'store.installments.use.creditAmountHint': 'Allowed credit: {min} – {max}', + 'store.installments.use.months': 'Repayment months', + 'store.installments.use.monthsOption': '{months} months', + 'store.installments.use.monthsHint': 'Options use step {step}', + 'store.installments.use.errorMinShopping': + 'Purchase value must be at least {amount} for this method.', + 'store.installments.use.errorCreditOverTotal': + 'Credit cannot be greater than the purchase value.', + 'store.installments.use.errorCreditRange': + 'Credit must be between {min} and {max}.', + 'store.installments.use.resultEmpty': + 'Enter a valid purchase value and credit to see the payment plan.', + 'store.installments.use.result.totalValue': 'Total value', + 'store.installments.use.result.credit': 'Credit value', + 'store.installments.use.result.cash': 'Cash / down payment', + 'store.installments.use.result.fees': 'Total interest / fees', + 'store.installments.use.result.totalRepay': 'Total repayable', + 'store.installments.use.schedule.title': 'Installment schedule', + 'store.installments.use.schedule.count': '{count} payments', + 'store.installments.use.schedule.item': 'Payment {index}', + 'store.installments.form.add.subtitle': + 'Define how customers can buy with installments or facilities.', + 'store.installments.form.edit.subtitle': 'Update this installment method.', + 'store.installments.form.name': 'Name', + 'store.installments.form.nameFa': 'Name (FA)', + 'store.installments.form.nameEn': 'Name (EN)', + 'store.installments.form.namePlaceholder': 'e.g. 12-month credit', + 'store.installments.form.nameFaPlaceholder': 'مثلاً اعتبار ۱۲ ماهه', + 'store.installments.form.nameEnPlaceholder': 'e.g. 12-month credit', + 'store.installments.form.image': 'Image', + 'store.installments.form.uploadImage': 'Upload image', + 'store.installments.form.changeImage': 'Change image', + 'store.installments.form.cropHint': 'Crop the image, then apply.', + 'store.installments.form.aspectAria': 'Image aspect ratio', + 'store.installments.form.minShoppingAmount': 'Min shopping', + 'store.installments.form.minShoppingAmount.hint': + 'Cart total must reach this amount before this facility can be used.', + 'store.installments.form.minAmount': 'Min loan', + 'store.installments.form.minAmount.hint': 'Smallest loan amount offered with this method.', + 'store.installments.form.maxAmount': 'Max loan', + 'store.installments.form.maxAmount.hint': 'Largest loan amount offered with this method.', + 'store.installments.form.interest': 'Interest %', + 'store.installments.form.interest.hint': 'Annual interest rate applied to the loan.', + 'store.installments.form.returnMonthsMin': 'Min months', + 'store.installments.form.returnMonthsMin.hint': 'Shortest repayment period in months.', + 'store.installments.form.returnMonthsMax': 'Max months', + 'store.installments.form.returnMonthsMax.hint': 'Longest repayment period in months.', + 'store.installments.form.returnMonthsStep': 'Step', + 'store.installments.form.returnMonthsStep.hint': + 'Choose 2, 3, 4, 6, or 12 — e.g. 3 → 6 → 9 → 12.', + 'store.installments.form.description': 'Description', + 'store.installments.form.descriptionPlaceholder': 'Terms for customers…', + 'store.installments.form.cancel': 'Cancel', + 'store.installments.form.save': 'Save', + 'store.installments.form.update': 'Update', + 'store.installments.form.saving': 'Saving…', + 'store.installments.form.errorRequired': 'Please fill in all required fields.', + 'store.installments.form.errorAmountRange': + 'Minimum loan amount cannot be greater than maximum.', + 'store.installments.form.errorMonthsRange': + 'Minimum return months cannot be greater than maximum.', + 'store.installments.form.errorMonthsStep': 'Choose a valid month step.', + 'store.installments.form.errorMonthsStepReach': + 'Max months must be reachable from min months using the step.', + 'store.installments.form.errorSave': 'Failed to save installment method.', 'storeItems.subtitle': 'Products for sale with one or more priced variants.', 'storeItems.filters': 'Filters', @@ -2253,6 +2357,9 @@ const en = { 'title.storeItems': 'My Store Items', 'title.orders': 'My Orders', 'title.shoppingCards': 'Shopping Cards', + 'title.installments': 'Installments & Facilities', + 'title.addInstallment': 'Add Installment Method', + 'title.editInstallment': 'Edit Installment Method', 'title.customers': 'Users', 'title.customerProducts': 'Customer products', 'title.content': 'Content', @@ -2691,6 +2798,7 @@ const fa: Record = { 'nav.store.orders': 'سفارش‌های من', 'nav.store.shipping': 'هزینه ارسال', 'nav.store.cards': 'کارت‌های خرید', + 'nav.store.installments': 'اقساط و تسهیلات', 'nav.store.settings': 'تنظیمات', 'nav.customers': 'کاربران', 'nav.customerProducts': 'محصولات مشتریان', @@ -2934,26 +3042,26 @@ const fa: Record = { 'home.chart.blogViews.legend': 'بازدید ({count})', 'home.chart.blogViews.loading': 'در حال بارگذاری نمودار...', 'home.chart.blogViews.error': 'بارگذاری بازدید مقالات ممکن نشد.', - 'home.chart.blogViews.bar': '{day}: {count} بازدید', + 'home.chart.blogViews.bar': 'تعداد بازدید در {day} {count} بار', 'home.chart.productViews.title': 'بازدید محصولات', 'home.chart.productViews.subtitle': 'بازدید صفحات محصول و محصولات کاربران در ۳۰ روز گذشته از {from} تا {to}', 'home.chart.productViews.legend': 'بازدید ({count})', 'home.chart.productViews.loading': 'در حال بارگذاری نمودار...', 'home.chart.productViews.error': 'بارگذاری بازدید محصولات ممکن نشد.', - 'home.chart.productViews.bar': '{day}: {count} بازدید', + 'home.chart.productViews.bar': 'تعداد بازدید در {day} {count} بار', 'home.chart.websiteViews.title': 'بازدید وب‌سایت', 'home.chart.websiteViews.subtitle': 'بازدید صفحات فروشگاه در ۳۰ روز گذشته از {from} تا {to}', 'home.chart.websiteViews.legend': 'بازدید ({count})', 'home.chart.websiteViews.loading': 'در حال بارگذاری نمودار...', 'home.chart.websiteViews.error': 'بارگذاری بازدید وب‌سایت ممکن نشد.', - 'home.chart.websiteViews.bar': '{day}: {count} بازدید', + 'home.chart.websiteViews.bar': 'تعداد بازدید در {day} {count} بار', 'home.chart.portfolioViews.title': 'بازدید نمونه‌کارها', 'home.chart.portfolioViews.subtitle': 'بازدید صفحات نمونه‌کار در ۳۰ روز گذشته از {from} تا {to}', 'home.chart.portfolioViews.legend': 'بازدید ({count})', 'home.chart.portfolioViews.loading': 'در حال بارگذاری نمودار...', 'home.chart.portfolioViews.error': 'بارگذاری بازدید نمونه‌کارها ممکن نشد.', - 'home.chart.portfolioViews.bar': '{day}: {count} بازدید', + 'home.chart.portfolioViews.bar': 'تعداد بازدید در {day} {count} بار', 'statistics.page.subtitle': 'بازدید وب‌سایت و فعالیت فروشگاه شما.', 'statistics.errorLoad': 'بارگذاری آمار بازدید ممکن نشد.', @@ -3566,8 +3674,111 @@ const fa: Record = { 'store.card.shipping.link': 'مدیریت ارسال', 'store.card.cards.desc': 'کارت‌های خرید ذخیره‌شده و روش‌های پرداخت را مدیریت کنید.', 'store.card.cards.link': 'مشاهده کارت‌ها', + 'store.card.installments.desc': + 'روش‌های اقساط و تسهیلات قابل ارائه به مشتریان را تعریف کنید.', + 'store.card.installments.link': 'مدیریت اقساط', 'store.card.settings.desc': 'ترجیحات، صفحات و ظاهر فروشگاه را پیکربندی کنید.', 'store.card.settings.link': 'مشاهده تنظیمات', + 'store.installments.page.subtitle': + 'روش‌های وام و اقساطی که فروشگاه به مشتریان ارائه می‌دهد را بسازید.', + 'store.installments.loading': 'در حال بارگذاری روش‌های اقساط…', + 'store.installments.empty': 'هنوز روش اقساطی تعریف نشده. اولین مورد را اضافه کنید.', + 'store.installments.add': 'افزودن روش اقساط', + 'store.installments.errorLoad': 'بارگذاری روش‌های اقساط ناموفق بود.', + 'store.installments.errorDelete': 'حذف روش اقساط ناموفق بود.', + 'store.installments.deleteTitle': 'حذف روش اقساط', + 'store.installments.deleteMessage': + 'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.', + 'store.installments.toast.created': 'روش اقساط ایجاد شد.', + 'store.installments.toast.updated': 'روش اقساط به‌روزرسانی شد.', + 'store.installments.toast.deleted': 'روش اقساط حذف شد.', + 'store.installments.tooltip.edit': 'ویرایش', + 'store.installments.tooltip.remove': 'حذف', + 'store.installments.tooltip.use': 'استفاده / محاسبه', + 'store.installments.row.summary': + 'حداقل خرید {minShopping} · وام {minAmount}–{maxAmount} · سود {interest}٪ · {monthsMin}–{monthsMax} ماه (گام {monthsStep})', + 'store.installments.row.minShopping': 'حداقل خرید', + 'store.installments.row.creditAmount': 'مبلغ اعتبار', + 'store.installments.row.interest': 'سود', + 'store.installments.row.months': 'بازپرداخت', + 'store.installments.row.monthsUnit': 'ماه', + 'store.installments.row.monthsStep': 'گام {step}', + 'store.installments.use.badge': 'ماشین‌حساب اقساط', + 'store.installments.use.subtitle': + 'مبلغ خرید را وارد کنید، اعتبار و مدت را انتخاب کنید و برنامه بازپرداخت را ببینید.', + 'store.installments.use.section.value': '۱. مبلغ خرید', + 'store.installments.use.section.plan': '۲. اعتبار و مدت بازپرداخت', + 'store.installments.use.section.result': '۳. برنامه پرداخت', + 'store.installments.use.totalValue': 'مبلغ کل', + 'store.installments.use.totalValuePlaceholder': 'مثلاً ۱۰,۰۰۰,۰۰۰', + 'store.installments.use.totalValueHint': 'حداقل خرید این روش: {amount}', + 'store.installments.use.creditAmount': 'مبلغ اعتبار', + 'store.installments.use.creditAmountPlaceholder': 'مبلغ تأمین‌شده از اعتبار', + 'store.installments.use.creditAmountHint': 'بازه مجاز اعتبار: {min} – {max}', + 'store.installments.use.months': 'مدت بازپرداخت', + 'store.installments.use.monthsOption': '{months} ماه', + 'store.installments.use.monthsHint': 'گزینه‌ها با گام {step}', + 'store.installments.use.errorMinShopping': + 'مبلغ خرید باید حداقل {amount} باشد.', + 'store.installments.use.errorCreditOverTotal': + 'مبلغ اعتبار نمی‌تواند بیشتر از مبلغ خرید باشد.', + 'store.installments.use.errorCreditRange': + 'مبلغ اعتبار باید بین {min} و {max} باشد.', + 'store.installments.use.resultEmpty': + 'برای دیدن برنامه پرداخت، مبلغ خرید و اعتبار معتبر وارد کنید.', + 'store.installments.use.result.totalValue': 'مبلغ کل', + 'store.installments.use.result.credit': 'مبلغ اعتبار', + 'store.installments.use.result.cash': 'پرداخت نقدی / پیش‌پرداخت', + 'store.installments.use.result.fees': 'جمع سود / کارمزد', + 'store.installments.use.result.totalRepay': 'جمع بازپرداخت اقساط', + 'store.installments.use.schedule.title': 'جدول اقساط', + 'store.installments.use.schedule.count': '{count} قسط', + 'store.installments.use.schedule.item': 'قسط {index}', + 'store.installments.form.add.subtitle': + 'نحوه خرید اقساطی یا تسهیلاتی مشتریان را تعریف کنید.', + 'store.installments.form.edit.subtitle': 'این روش اقساط را ویرایش کنید.', + 'store.installments.form.name': 'نام', + 'store.installments.form.nameFa': 'نام فارسی', + 'store.installments.form.nameEn': 'نام انگلیسی', + 'store.installments.form.namePlaceholder': 'مثلاً اعتبار ۱۲ ماهه', + 'store.installments.form.nameFaPlaceholder': 'مثلاً اعتبار ۱۲ ماهه', + 'store.installments.form.nameEnPlaceholder': 'e.g. 12-month credit', + 'store.installments.form.image': 'تصویر', + 'store.installments.form.uploadImage': 'آپلود تصویر', + 'store.installments.form.changeImage': 'تغییر تصویر', + 'store.installments.form.cropHint': 'تصویر را برش دهید و اعمال کنید.', + 'store.installments.form.aspectAria': 'نسبت تصویر', + 'store.installments.form.minShoppingAmount': 'حداقل خرید', + 'store.installments.form.minShoppingAmount.hint': + 'حداقل مبلغ سبد خرید برای فعال‌شدن این تسهیلات.', + 'store.installments.form.minAmount': 'حداقل وام', + 'store.installments.form.minAmount.hint': 'کمترین مبلغ وامی که با این روش ارائه می‌شود.', + 'store.installments.form.maxAmount': 'حداکثر وام', + 'store.installments.form.maxAmount.hint': 'بیشترین مبلغ وامی که با این روش ارائه می‌شود.', + 'store.installments.form.interest': 'سود ٪', + 'store.installments.form.interest.hint': 'نرخ سود سالانه اعمال‌شده روی مبلغ وام.', + 'store.installments.form.returnMonthsMin': 'حداقل ماه', + 'store.installments.form.returnMonthsMin.hint': 'کوتاه‌ترین دوره بازپرداخت به ماه.', + 'store.installments.form.returnMonthsMax': 'حداکثر ماه', + 'store.installments.form.returnMonthsMax.hint': 'بلندترین دوره بازپرداخت به ماه.', + 'store.installments.form.returnMonthsStep': 'گام', + 'store.installments.form.returnMonthsStep.hint': + 'یکی از ۲، ۳، ۴، ۶ یا ۱۲ — مثلاً ۳ ← ۶ ← ۹ ← ۱۲.', + 'store.installments.form.description': 'توضیحات', + 'store.installments.form.descriptionPlaceholder': 'شرایط برای مشتریان…', + 'store.installments.form.cancel': 'انصراف', + 'store.installments.form.save': 'ذخیره', + 'store.installments.form.update': 'به‌روزرسانی', + 'store.installments.form.saving': 'در حال ذخیره…', + 'store.installments.form.errorRequired': 'لطفاً همه فیلدهای الزامی را پر کنید.', + 'store.installments.form.errorAmountRange': + 'حداقل مبلغ وام نمی‌تواند بیشتر از حداکثر باشد.', + 'store.installments.form.errorMonthsRange': + 'حداقل ماه بازپرداخت نمی‌تواند بیشتر از حداکثر باشد.', + 'store.installments.form.errorMonthsStep': 'یک گام ماه معتبر انتخاب کنید.', + 'store.installments.form.errorMonthsStepReach': + 'حداکثر ماه باید با گام از حداقل ماه قابل دستیابی باشد.', + 'store.installments.form.errorSave': 'ذخیره روش اقساط ناموفق بود.', 'storeItems.subtitle': 'محصولات فروش با یک یا چند تنوع قیمت‌گذاری‌شده.', 'storeItems.filters': 'فیلترها', @@ -4909,6 +5120,9 @@ const fa: Record = { 'title.storeItems': 'محصولات فروشگاه', 'title.orders': 'سفارش‌های من', 'title.shoppingCards': 'کارت‌های خرید', + 'title.installments': 'اقساط و تسهیلات', + 'title.addInstallment': 'افزودن روش اقساط', + 'title.editInstallment': 'ویرایش روش اقساط', 'title.customers': 'کاربران', 'title.customerProducts': 'محصولات مشتریان', 'title.content': 'مدیریت محتوا', @@ -5341,6 +5555,9 @@ const BREADCRUMB_LABEL_KEYS: Record = { 'My Orders': 'nav.store.orders', 'Shipping Fees': 'nav.store.shipping', 'Shopping Cards': 'nav.store.cards', + 'Installments & Facilities': 'nav.store.installments', + 'Add Installment Method': 'title.addInstallment', + 'Edit Installment Method': 'title.editInstallment', Customers: 'nav.customers', 'Customer products': 'nav.customerProducts', Blog: 'nav.blog', @@ -5426,6 +5643,15 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR { match: '/store/items', labels: [t('title.store'), t('title.storeItems')] }, { match: '/store/orders', labels: [t('title.store'), t('title.orders')] }, { match: '/store/cards', labels: [t('title.store'), t('title.shoppingCards')] }, + { + match: '/store/installments/new', + labels: [t('title.store'), t('title.installments'), t('title.addInstallment')], + }, + { + match: /^\/store\/installments\/edit\/[^/]+$/, + labels: [t('title.store'), t('title.installments'), t('title.editInstallment')], + }, + { match: '/store/installments', labels: [t('title.store'), t('title.installments')] }, { match: '/store/settings', labels: [t('title.store'), t('title.settings')] }, { match: '/store', labels: [t('title.store')] }, { match: '/customers', labels: [t('title.customers')] }, diff --git a/apps/business/src/pages/AddCustomerProductPage.tsx b/apps/business/src/pages/AddCustomerProductPage.tsx index e3e285c..a50d833 100644 --- a/apps/business/src/pages/AddCustomerProductPage.tsx +++ b/apps/business/src/pages/AddCustomerProductPage.tsx @@ -773,6 +773,7 @@ export function AddCustomerProductPage() { }} aspect={1} minZoom={0.4} + outputFormat="jpeg" hint={t('customerProducts.form.images.thumbnailHint')} />
diff --git a/apps/business/src/pages/AddNewProductPage.module.css b/apps/business/src/pages/AddNewProductPage.module.css index 2286e78..8e205c5 100644 --- a/apps/business/src/pages/AddNewProductPage.module.css +++ b/apps/business/src/pages/AddNewProductPage.module.css @@ -17,6 +17,7 @@ .col2 { grid-column: span 2; } .col3 { grid-column: span 3; } .col4 { grid-column: span 4; } +.col5 { grid-column: span 5; } .col6 { grid-column: 1 / span 6; } .col8 { grid-column: span 8; } .col7 { grid-column: span 7; } @@ -437,6 +438,7 @@ .col2, .col3, .col4, + .col5, .col6, .col6Span, .col7, diff --git a/apps/business/src/pages/AddNewProductPage.tsx b/apps/business/src/pages/AddNewProductPage.tsx index bbbb189..4d4d2b4 100644 --- a/apps/business/src/pages/AddNewProductPage.tsx +++ b/apps/business/src/pages/AddNewProductPage.tsx @@ -285,7 +285,12 @@ export function AddNewProductPage() {
- +
diff --git a/apps/business/src/pages/AddStoreInstallmentPage.module.css b/apps/business/src/pages/AddStoreInstallmentPage.module.css new file mode 100644 index 0000000..84ce88d --- /dev/null +++ b/apps/business/src/pages/AddStoreInstallmentPage.module.css @@ -0,0 +1,201 @@ +.layout { + display: grid; + grid-template-columns: minmax(160px, 200px) minmax(0, 1fr); + gap: 16px 20px; + align-items: start; +} + +.fields { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.row { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 10px 12px; + align-items: start; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-width: 0; + height: var(--field-height, 40px); + padding: 0 10px; + font-size: 13px; + color: var(--text); + background: var(--surface); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-sm); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.field select { + cursor: pointer; + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 16px) calc(50% - 2px), + calc(100% - 11px) calc(50% - 2px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-inline-end: 28px; +} + +:global([dir='rtl']) .field select { + background-position: + 11px calc(50% - 2px), + 16px calc(50% - 2px); +} + +.field textarea { + height: auto; + min-height: 84px; + padding-top: 10px; + padding-bottom: 10px; + resize: vertical; + line-height: 1.45; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.field input:disabled, +.field select:disabled, +.field textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.hint { + margin: 0; + font-size: 11px; + line-height: 1.35; + color: var(--text-muted); +} + +.nameFa, +.nameEn { + grid-column: span 6; +} + +.descriptionFull { + grid-column: 1 / -1; +} + +.minShopping, +.interest { + grid-column: span 4; +} + +.loanGroup, +.monthsGroup { + grid-column: span 8; +} + +.group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + min-width: 0; + padding-inline: 10px; + border-inline-start: 1px solid rgba(148, 163, 184, 0.35); + border-inline-end: 1px solid rgba(148, 163, 184, 0.35); +} + +.monthsGroup { + grid-template-columns: 1fr 1fr 1fr; +} + +.imageCol { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} + +.imageCol > label { + width: 100%; + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } + + .imageCol { + max-width: 220px; + margin-inline: auto; + } + + .row { + grid-template-columns: 1fr 1fr; + } + + .nameFa, + .nameEn, + .minShopping, + .loanGroup, + .interest, + .monthsGroup, + .descriptionFull { + grid-column: span 1; + } + + .loanGroup, + .monthsGroup, + .descriptionFull { + grid-column: 1 / -1; + } +} + +@media (max-width: 560px) { + .row { + grid-template-columns: 1fr; + } + + .nameFa, + .nameEn, + .minShopping, + .loanGroup, + .interest, + .monthsGroup, + .descriptionFull { + grid-column: 1 / -1; + } + + .group { + grid-template-columns: 1fr; + } +} diff --git a/apps/business/src/pages/AddStoreInstallmentPage.tsx b/apps/business/src/pages/AddStoreInstallmentPage.tsx new file mode 100644 index 0000000..69ebcea --- /dev/null +++ b/apps/business/src/pages/AddStoreInstallmentPage.tsx @@ -0,0 +1,505 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { ImageCropper } from '../components/ImageCropper' +import { Breadcrumbs } from '../components/Breadcrumbs' +import { useToast } from '../context/ToastContext' +import { useT } from '../i18n/useT' +import { ApiError } from '../lib/api' +import { resolveDataUrlToMediaId } from '../services/mediaService' +import { + createStoreLoanMethod, + getStoreLoanMethod, + mapStoreLoanMethodApiToUi, + updateStoreLoanMethod, +} from '../services/storeLoanMethodService' +import type { StoreLoanImageAspect } from '../types/storeLoanMethod' +import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' +import pageStyles from '../components/PageContent.module.css' +import formStyles from './AddNewProductPage.module.css' +import styles from './AddStoreInstallmentPage.module.css' + +const IMAGE_ASPECTS: { id: StoreLoanImageAspect; aspect: number }[] = [ + { id: '1:1', aspect: 1 }, + { id: '3:2', aspect: 3 / 2 }, +] + +const MONTH_STEP_OPTIONS = [2, 3, 4, 6, 12] as const +const DEFAULT_MONTH_STEP = '3' + +function normalizeMonthStep(value: number | string | null | undefined): string { + const parsed = Number(value) + return (MONTH_STEP_OPTIONS as readonly number[]).includes(parsed) + ? String(parsed) + : DEFAULT_MONTH_STEP +} + +function parseNumberInput(value: string): number | null { + const trimmed = value.trim().replace(/,/g, '') + if (!trimmed) return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null +} + +function mediaFilenameFromDataUrl(value: string | null): string { + if (value?.startsWith('data:image/png')) return 'store-loan-method.png' + return 'store-loan-method.jpg' +} + +export function AddStoreInstallmentPage() { + const { id } = useParams() + const navigate = useNavigate() + const { showToast } = useToast() + const t = useT() + const isEdit = Boolean(id) + + const [nameFa, setNameFa] = useState('') + const [nameEn, setNameEn] = useState('') + const [description, setDescription] = useState('') + const [image, setImage] = useState(null) + const [imageMediaId, setImageMediaId] = useState(null) + const [imageAspect, setImageAspect] = useState('1:1') + const [minShoppingAmount, setMinShoppingAmount] = useState('') + const [minAmount, setMinAmount] = useState('') + const [maxAmount, setMaxAmount] = useState('') + const [interest, setInterest] = useState('') + const [returnMonthsMin, setReturnMonthsMin] = useState('') + const [returnMonthsMax, setReturnMonthsMax] = useState('') + const [returnMonthsStep, setReturnMonthsStep] = useState(DEFAULT_MONTH_STEP) + const [loaded, setLoaded] = useState(!isEdit) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + + const selectedAspect = + IMAGE_ASPECTS.find((option) => option.id === imageAspect)?.aspect ?? 1 + + useEffect(() => { + if (!isEdit || !id) return + + const controller = new AbortController() + + async function loadMethod() { + setError('') + try { + const data = await getStoreLoanMethod(id!, controller.signal) + const method = mapStoreLoanMethodApiToUi(data.method) + setNameFa(method.nameFa) + setNameEn(method.nameEn) + setDescription(method.description) + setImage(method.imageUrl) + setImageMediaId(method.imageMediaId) + setImageAspect(method.imageAspectRatio) + setMinShoppingAmount( + method.minShoppingAmount != null + ? formatIrtInput(String(Math.round(method.minShoppingAmount))) + : '', + ) + setMinAmount( + method.minAmount != null + ? formatIrtInput(String(Math.round(method.minAmount))) + : '', + ) + setMaxAmount( + method.maxAmount != null + ? formatIrtInput(String(Math.round(method.maxAmount))) + : '', + ) + setInterest(String(method.interest)) + setReturnMonthsMin(String(method.returnMonthsMin)) + setReturnMonthsMax(String(method.returnMonthsMax)) + setReturnMonthsStep(normalizeMonthStep(method.returnMonthsStep)) + setLoaded(true) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + navigate('/store/installments', { replace: true }) + } + } + + void loadMethod() + return () => controller.abort() + }, [isEdit, id, navigate]) + + if (!loaded) return null + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsSubmitting(true) + setError('') + + const parsedMinShopping = parseIrtInput(minShoppingAmount) + const parsedMinAmount = parseIrtInput(minAmount) + const parsedMaxAmount = parseIrtInput(maxAmount) + const parsedInterest = parseNumberInput(interest) + const parsedMonthsMin = parseNumberInput(returnMonthsMin) + const parsedMonthsMax = parseNumberInput(returnMonthsMax) + const parsedMonthsStep = parseNumberInput(returnMonthsStep) + + if ( + !nameFa.trim() || + !nameEn.trim() || + parsedMinShopping === null || + parsedMinAmount === null || + parsedMaxAmount === null || + parsedInterest === null || + parsedMonthsMin === null || + parsedMonthsMax === null || + parsedMonthsStep === null + ) { + setError(t('store.installments.form.errorRequired')) + setIsSubmitting(false) + return + } + + if (parsedMinAmount > parsedMaxAmount) { + setError(t('store.installments.form.errorAmountRange')) + setIsSubmitting(false) + return + } + + if (parsedMonthsMin > parsedMonthsMax) { + setError(t('store.installments.form.errorMonthsRange')) + setIsSubmitting(false) + return + } + + if (!(MONTH_STEP_OPTIONS as readonly number[]).includes(parsedMonthsStep)) { + setError(t('store.installments.form.errorMonthsStep')) + setIsSubmitting(false) + return + } + + if ((parsedMonthsMax - parsedMonthsMin) % parsedMonthsStep !== 0) { + setError(t('store.installments.form.errorMonthsStepReach')) + setIsSubmitting(false) + return + } + + try { + const nextImageMediaId = await resolveDataUrlToMediaId( + image, + mediaFilenameFromDataUrl(image), + imageMediaId, + ) + + const payload = { + nameFa: nameFa.trim(), + nameEn: nameEn.trim(), + description: description.trim() || null, + imageAspectRatio: imageAspect, + minShoppingAmount: parsedMinShopping, + minAmount: parsedMinAmount, + maxAmount: parsedMaxAmount, + interest: parsedInterest, + returnMonthsMin: Math.round(parsedMonthsMin), + returnMonthsMax: Math.round(parsedMonthsMax), + returnMonthsStep: Math.round(parsedMonthsStep), + ...(isEdit + ? { imageMediaId: nextImageMediaId } + : nextImageMediaId + ? { imageMediaId: nextImageMediaId } + : {}), + } + + if (isEdit && id) { + await updateStoreLoanMethod(id, payload) + showToast(t('store.installments.toast.updated'), 'success') + } else { + await createStoreLoanMethod({ + ...payload, + description: description.trim() || undefined, + }) + showToast(t('store.installments.toast.created'), 'success') + } + + navigate('/store/installments') + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else if (err instanceof Error) { + setError(err.message) + } else { + setError(t('store.installments.form.errorSave')) + } + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ +
+
+

+ {isEdit + ? t('title.editInstallment') + : t('title.addInstallment')} +

+

+ {isEdit + ? t('store.installments.form.edit.subtitle') + : t('store.installments.form.add.subtitle')} +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + +
+ {IMAGE_ASPECTS.map((option) => ( + + ))} +
+
+ +
+
+
+ + setNameFa(e.target.value)} + placeholder={t('store.installments.form.nameFaPlaceholder')} + required + disabled={isSubmitting} + /> +
+ +
+ + setNameEn(e.target.value)} + placeholder={t('store.installments.form.nameEnPlaceholder')} + required + disabled={isSubmitting} + dir="ltr" + /> +
+
+ +
+
+ +