+
{t('orderItems.delivery')}
+ {shippingInfo.deliveryMode === 'pickup' ? (
+
{t('orderItems.pickup')}
+ ) : shippingInfo.addressLine ? (
+
{shippingInfo.addressLine}
+ ) : null}
+
+ {shippingInfo.groups.length > 0 ? (
+
+ ) : null}
+
+ )
+ })()}
+
{order.items.length === 0 ? (
-
- {t('orderItems.summary', {
- count: itemCount,
- itemsLabel:
- itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
- })}
-
-
{formatIrtPrice(order.total)}
+
+
+ {t('orderItems.subtotal')}
+ {formatIrtPrice(order.subtotal)}
+
+
+ {t('orderItems.shipping')}
+
+ {order.shippingTotal > 0
+ ? formatIrtPrice(order.shippingTotal)
+ : t('orderItems.freeShipping')}
+
+
+ {order.discountTotal > 0 ? (
+
+ {t('orderItems.discount')}
+ {formatIrtPrice(order.discountTotal)}
+
+ ) : null}
+
+
+ {t('orderItems.summary', {
+ count: itemCount,
+ itemsLabel:
+ itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
+ })}
+
+ {formatIrtPrice(order.total)}
+
diff --git a/apps/business/src/components/ShippingCostGroupModal.module.css b/apps/business/src/components/ShippingCostGroupModal.module.css
new file mode 100644
index 0000000..11e2e12
--- /dev/null
+++ b/apps/business/src/components/ShippingCostGroupModal.module.css
@@ -0,0 +1,331 @@
+.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: 200;
+ padding: 20px;
+}
+
+.modal {
+ width: 100%;
+ max-width: 560px;
+ max-height: calc(100vh - 40px);
+ overflow: auto;
+ background: var(--surface);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px 24px 0;
+}
+
+.title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.closeBtn {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ color: var(--text-secondary);
+ transition: background 0.2s, color 0.2s;
+}
+
+.closeBtn:hover:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.closeBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.form {
+ padding: 20px 24px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.error {
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ color: #b91c1c;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field label,
+.fieldLabel {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.hint {
+ margin: 0 0 4px;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+
+.field input,
+.field textarea {
+ padding: var(--field-padding-y) var(--field-padding-x);
+ font-size: var(--field-font-size);
+ line-height: 1.4;
+ color: var(--text-primary);
+ background: var(--surface);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.field input {
+ min-height: var(--field-height);
+}
+
+.field input:focus,
+.field textarea:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
+.field textarea {
+ resize: vertical;
+ min-height: calc(var(--field-height) + 24px);
+}
+
+.chipGrid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ font-size: 13px;
+ font-weight: 500;
+ font-family: var(--font-ui);
+ color: var(--text-secondary);
+ background: var(--surface);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: 50px;
+ transition: all 0.15s;
+ cursor: pointer;
+}
+
+.chip:hover:not(:disabled) {
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+.chip:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.chipSelected {
+ background: rgba(var(--primary-rgb) / 0.12);
+ border-color: var(--primary);
+ color: var(--primary);
+ box-shadow: 0 0 0 1px rgba(var(--primary-rgb) / 0.2);
+}
+
+.chipFa {
+ font-weight: 600;
+}
+
+.chipEn {
+ font-size: 11px;
+ font-weight: 600;
+ opacity: 0.72;
+ font-family: var(--font-en), var(--font-ui);
+ letter-spacing: 0.01em;
+}
+
+.chipSelected .chipEn {
+ opacity: 0.85;
+}
+
+.amountRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.amountRow input {
+ flex: 1;
+ min-width: 0;
+}
+
+.amountUnit {
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+.checkRow {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background: rgba(148, 163, 184, 0.06);
+ cursor: pointer;
+ user-select: none;
+}
+
+.checkRow input {
+ width: 16px;
+ height: 16px;
+ margin-top: 2px;
+ flex-shrink: 0;
+ accent-color: var(--primary);
+ cursor: pointer;
+}
+
+.checkText {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.checkTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.checkHint {
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 4px;
+ direction: rtl;
+}
+
+.cancelBtn {
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ transition: background 0.2s;
+}
+
+.cancelBtn:hover:not(:disabled) {
+ background: rgba(148, 163, 184, 0.15);
+}
+
+.submitBtn {
+ padding: 8px 18px;
+ font-size: 13px;
+ font-weight: 600;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.submitBtn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
+}
+
+.submitBtn:disabled,
+.cancelBtn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.overlayIn {
+ animation: overlayFadeIn 0.2s ease forwards;
+}
+
+.overlayOut {
+ animation: overlayFadeOut 0.2s ease forwards;
+}
+
+.modalIn {
+ animation: modalFadeIn 0.25s ease forwards;
+}
+
+.modalOut {
+ animation: modalFadeOut 0.22s ease forwards;
+}
+
+@keyframes overlayFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes overlayFadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes modalFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(12px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes modalFadeOut {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+}
diff --git a/apps/business/src/components/ShippingCostGroupModal.tsx b/apps/business/src/components/ShippingCostGroupModal.tsx
new file mode 100644
index 0000000..694fd7a
--- /dev/null
+++ b/apps/business/src/components/ShippingCostGroupModal.tsx
@@ -0,0 +1,294 @@
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import { useT } from '../i18n/useT'
+import type {
+ ShippingCostGroup,
+ ShippingCostGroupFormPayload,
+ ShippingMethodId,
+} from '../types/shippingCostGroup'
+import { SHIPPING_METHOD_IDS } from '../types/shippingCostGroup'
+import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
+import styles from './ShippingCostGroupModal.module.css'
+
+const METHOD_EN_LABELS: Record
= {
+ post: 'Post',
+ tipax: 'Tipax',
+ mahex: 'Mahex',
+ freight: 'Freight',
+ air: 'Air',
+}
+
+interface ShippingCostGroupModalProps {
+ open: boolean
+ onClose: () => void
+ onSubmit: (data: ShippingCostGroupFormPayload) => void
+ editingGroup?: ShippingCostGroup | null
+ isSubmitting?: boolean
+}
+
+const ANIMATION_MS = 220
+
+export function ShippingCostGroupModal({
+ open,
+ onClose,
+ onSubmit,
+ editingGroup = null,
+ isSubmitting = false,
+}: ShippingCostGroupModalProps) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const [mounted, setMounted] = useState(open)
+ const [closing, setClosing] = useState(false)
+ const [nameFa, setNameFa] = useState('')
+ const [nameEn, setNameEn] = useState('')
+ const [description, setDescription] = useState('')
+ const [methods, setMethods] = useState([])
+ const [defaultAmount, setDefaultAmount] = useState('')
+ const [isDefault, setIsDefault] = useState(false)
+ const [formError, setFormError] = useState('')
+
+ useEffect(() => {
+ if (open) {
+ setMounted(true)
+ setClosing(false)
+ setNameFa(editingGroup?.nameFa ?? '')
+ setNameEn(editingGroup?.nameEn ?? '')
+ setDescription(editingGroup?.description ?? '')
+ setMethods(editingGroup?.methods ?? [])
+ setDefaultAmount(
+ editingGroup
+ ? formatIrtInput(String(Math.round(editingGroup.defaultAmount)))
+ : '',
+ )
+ setIsDefault(editingGroup?.isDefault ?? false)
+ setFormError('')
+ } else if (mounted) {
+ setClosing(true)
+ const timer = setTimeout(() => {
+ setMounted(false)
+ setClosing(false)
+ }, ANIMATION_MS)
+ return () => clearTimeout(timer)
+ }
+ }, [open, editingGroup, mounted])
+
+ useEffect(() => {
+ if (!mounted || closing) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !isSubmitting) onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [mounted, closing, onClose, isSubmitting])
+
+ if (!mounted) return null
+
+ function toggleMethod(method: ShippingMethodId) {
+ setMethods((prev) =>
+ prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method],
+ )
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setFormError('')
+
+ const trimmedFa = nameFa.trim()
+ const trimmedEn = nameEn.trim()
+ const amount = parseIrtInput(defaultAmount)
+
+ if (!trimmedFa || !trimmedEn) {
+ setFormError(t('store.shipping.form.errorRequired'))
+ return
+ }
+ if (!methods.length) {
+ setFormError(t('store.shipping.form.errorMethods'))
+ return
+ }
+ if (amount === null || amount < 0) {
+ setFormError(t('store.shipping.form.errorAmount'))
+ return
+ }
+
+ onSubmit({
+ nameFa: trimmedFa,
+ nameEn: trimmedEn,
+ description: description.trim() || undefined,
+ methods,
+ defaultAmount: amount,
+ isDefault,
+ })
+ }
+
+ return createPortal(
+ !isSubmitting && onClose()}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="shipping-group-modal-title"
+ lang={isFa ? 'fa' : 'en'}
+ dir={isFa ? 'rtl' : 'ltr'}
+ >
+
+
+ {editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')}
+
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/ShippingCostGroupRow.module.css b/apps/business/src/components/ShippingCostGroupRow.module.css
new file mode 100644
index 0000000..1771558
--- /dev/null
+++ b/apps/business/src/components/ShippingCostGroupRow.module.css
@@ -0,0 +1,264 @@
+.row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 14px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ transition: background 0.2s, opacity 0.2s, border-color 0.2s;
+}
+
+.row:hover {
+ background: rgba(var(--primary-rgb) / 0.04);
+ border-color: rgba(var(--primary-rgb) / 0.28);
+}
+
+.rowDisabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
+
+.titleBlock {
+ flex: 1 1 320px;
+ min-width: 220px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.names {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.nameFa {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nameSep {
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.nameEn {
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted);
+ direction: ltr;
+ unicode-bidi: isolate;
+ font-family: var(--font-en), var(--font-ui);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 45%;
+}
+
+.defaultBadge {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ min-height: 20px;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--primary);
+ background: rgba(var(--primary-rgb) / 0.12);
+ border: 1px solid rgba(var(--primary-rgb) / 0.2);
+}
+
+.description {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.35;
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.metrics {
+ flex: 0 1 auto;
+ max-width: 48%;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-inline-start: auto;
+}
+
+.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;
+ align-items: baseline;
+ 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;
+}
+
+.methodChip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 28px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--primary);
+ background: rgba(var(--primary-rgb) / 0.1);
+ border: 1px solid rgba(var(--primary-rgb) / 0.18);
+ white-space: nowrap;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ margin-inline-start: 12px;
+ padding-inline-start: 14px;
+ border-inline-start: 1px solid rgba(148, 163, 184, 0.28);
+}
+
+.controlBtn {
+ position: relative;
+ 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:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.controlBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.exceptionBtn {
+ color: var(--primary);
+}
+
+.exceptionBadge {
+ position: absolute;
+ top: 2px;
+ inset-inline-end: 2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ color: white;
+ background: var(--primary);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+.categoryBadge {
+ position: absolute;
+ top: 2px;
+ inset-inline-end: 2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+ background: #ffffff;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+:global(html:not([data-theme='dark'])) .categoryBadge {
+ color: #0f172a;
+ background: #f8fafc;
+ border-color: rgba(148, 163, 184, 0.5);
+}
+
+.danger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.12);
+ color: #ef4444;
+}
+
+@media (max-width: 900px) {
+ .row {
+ flex-wrap: wrap;
+ }
+
+ .titleBlock {
+ flex: 1 1 100%;
+ min-width: 0;
+ }
+
+ .metrics {
+ flex: 1 1 auto;
+ max-width: none;
+ order: 3;
+ margin-inline-start: 0;
+ justify-content: flex-start;
+ }
+
+ .controls {
+ margin-inline-start: auto;
+ padding-inline-start: 0;
+ border-inline-start: none;
+ }
+}
diff --git a/apps/business/src/components/ShippingCostGroupRow.tsx b/apps/business/src/components/ShippingCostGroupRow.tsx
new file mode 100644
index 0000000..c0005f1
--- /dev/null
+++ b/apps/business/src/components/ShippingCostGroupRow.tsx
@@ -0,0 +1,127 @@
+import { Layers, MapPin, Pencil, Trash2 } from 'lucide-react'
+import type { ShippingCostGroup, ShippingMethodId } from '../types/shippingCostGroup'
+import { getGroupProvinceExceptions } from '../services/shippingCostGroupService'
+import { useT } from '../i18n/useT'
+import { formatIrtPrice } from '../utils/irtPrice'
+import { Tooltip } from './Tooltip'
+import styles from './ShippingCostGroupRow.module.css'
+
+interface ShippingCostGroupRowProps {
+ group: ShippingCostGroup
+ disabled?: boolean
+ onEdit: (id: string) => void
+ onRemove: (id: string) => void
+ onExceptions: (id: string) => void
+ onCategories: (id: string) => void
+}
+
+function methodLabelKey(method: ShippingMethodId) {
+ return `store.shipping.method.${method}` as const
+}
+
+export function ShippingCostGroupRow({
+ group,
+ disabled = false,
+ onEdit,
+ onRemove,
+ onExceptions,
+ onCategories,
+}: ShippingCostGroupRowProps) {
+ const t = useT()
+ const nameFa = group.nameFa?.trim() || ''
+ const nameEn = group.nameEn?.trim() || ''
+ const exceptionCount = getGroupProvinceExceptions(group).length
+ const categoryCount = group.categoryIds?.length ?? group.categories?.length ?? 0
+
+ return (
+
+
+
+ {nameFa ? {nameFa} : null}
+ {nameFa && nameEn ? · : null}
+ {nameEn ? (
+
+ {nameEn}
+
+ ) : null}
+ {group.isDefault ? (
+
+ {t('store.shipping.row.defaultBadge')}
+
+ ) : null}
+
+ {group.description ? (
+
{group.description}
+ ) : null}
+
+
+
+
+ {t('store.shipping.row.defaultAmount')}
+
+ {formatIrtPrice(group.defaultAmount)}
+
+
+
+ {group.methods.map((method) => (
+
+ {t(methodLabelKey(method))}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/business/src/components/ShippingGroupCategoriesModal.tsx b/apps/business/src/components/ShippingGroupCategoriesModal.tsx
new file mode 100644
index 0000000..17c4392
--- /dev/null
+++ b/apps/business/src/components/ShippingGroupCategoriesModal.tsx
@@ -0,0 +1,210 @@
+import { useEffect, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import { useT } from '../i18n/useT'
+import { ApiError } from '../lib/api'
+import {
+ listProductCategories,
+ mapProductCategoryToUi,
+} from '../services/productCategoryService'
+import { CategoryTreeMultiSelect } from './CategoryTreeMultiSelect'
+import type { CategoryTreeItem } from './CategoryTreeSelect'
+import modalStyles from './VariationsModal.module.css'
+import styles from './PickStoreSpecialItemsModal.module.css'
+
+interface ShippingGroupCategoriesModalProps {
+ open: boolean
+ groupName: string
+ selectedCategoryIds: string[]
+ onClose: () => void
+ onSave: (categoryIds: string[]) => Promise | void
+ isSubmitting?: boolean
+}
+
+const ANIMATION_MS = 220
+
+export function ShippingGroupCategoriesModal({
+ open,
+ groupName,
+ selectedCategoryIds,
+ onClose,
+ onSave,
+ isSubmitting = false,
+}: ShippingGroupCategoriesModalProps) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const [mounted, setMounted] = useState(open)
+ const [closing, setClosing] = useState(false)
+ const [categories, setCategories] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [selectedIds, setSelectedIds] = useState([])
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (open) {
+ setMounted(true)
+ setClosing(false)
+ setSelectedIds(selectedCategoryIds)
+ setError('')
+ } else if (mounted) {
+ setClosing(true)
+ const timer = setTimeout(() => {
+ setMounted(false)
+ setClosing(false)
+ }, ANIMATION_MS)
+ return () => clearTimeout(timer)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when open toggles
+ }, [open, mounted])
+
+ useEffect(() => {
+ if (!open) return
+
+ const controller = new AbortController()
+ void loadCategories(controller.signal)
+ return () => controller.abort()
+ }, [open])
+
+ useEffect(() => {
+ if (!mounted || closing) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !isSubmitting && !saving) onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [mounted, closing, onClose, isSubmitting, saving])
+
+ async function loadCategories(signal?: AbortSignal) {
+ setIsLoading(true)
+ setError('')
+
+ try {
+ const items = await listProductCategories(signal)
+ setCategories(
+ items.map(mapProductCategoryToUi).map((category) => ({
+ id: category.id,
+ parentId: category.parentId,
+ nameEn: category.nameEn,
+ nameFa: category.nameFa,
+ })),
+ )
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.groupCategories.errorLoad'))
+ }
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ function toggleSelection(categoryId: string) {
+ setSelectedIds((prev) =>
+ prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
+ )
+ }
+
+ async function handleSave() {
+ setSaving(true)
+ setError('')
+ try {
+ await onSave(selectedIds)
+ onClose()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.groupCategories.errorSave'))
+ }
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const busy = isSubmitting || saving
+ const treeReady = useMemo(() => categories.length > 0, [categories.length])
+
+ if (!mounted) return null
+
+ return createPortal(
+ e.target === e.currentTarget && !busy && onClose()}
+ role="presentation"
+ >
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+
+
+
+ {t('store.shipping.groupCategories.title')}
+
+
+ {t('store.shipping.groupCategories.subtitle', { name: groupName })}
+
+
+
+
+
+
+ {error ?
{error}
: null}
+
+ {isLoading && !treeReady ? (
+
{t('store.shipping.groupCategories.loading')}
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/ShippingGroupExceptionsModal.module.css b/apps/business/src/components/ShippingGroupExceptionsModal.module.css
new file mode 100644
index 0000000..070b5f1
--- /dev/null
+++ b/apps/business/src/components/ShippingGroupExceptionsModal.module.css
@@ -0,0 +1,200 @@
+.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: 200;
+ padding: 20px;
+}
+
+.modal {
+ width: 100%;
+ max-width: 560px;
+ max-height: calc(100vh - 40px);
+ display: flex;
+ flex-direction: column;
+ background: var(--surface);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
+ overflow: hidden;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 20px 20px 12px;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.title {
+ margin: 0 0 4px;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.subtitle {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--text-secondary);
+}
+
+.headerActions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.addBtn {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
+}
+
+.addBtn:disabled,
+.closeBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.closeBtn {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ color: var(--text-secondary);
+}
+
+.closeBtn:hover:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.body {
+ padding: 16px 20px 20px;
+ overflow: auto;
+}
+
+.empty {
+ margin: 0;
+ padding: 28px 8px;
+ text-align: center;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 11px 12px;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.14);
+}
+
+.row:last-child {
+ border-bottom: none;
+}
+
+.province {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.amount {
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+ unicode-bidi: isolate;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.controlBtn {
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: var(--text-secondary);
+}
+
+.controlBtn:hover:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.controlBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.danger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.12);
+ color: #ef4444;
+}
+
+.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
+.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
+.modalIn { animation: modalFadeIn 0.25s ease forwards; }
+.modalOut { animation: modalFadeOut 0.22s ease forwards; }
+
+@keyframes overlayFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes overlayFadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes modalFadeIn {
+ from { opacity: 0; transform: translateY(12px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes modalFadeOut {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(8px) scale(0.98); }
+}
diff --git a/apps/business/src/components/ShippingGroupExceptionsModal.tsx b/apps/business/src/components/ShippingGroupExceptionsModal.tsx
new file mode 100644
index 0000000..42caaac
--- /dev/null
+++ b/apps/business/src/components/ShippingGroupExceptionsModal.tsx
@@ -0,0 +1,265 @@
+import { useEffect, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Pencil, Plus, Trash2, X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import type { CityOption } from '../services/citiesService'
+import type { ShippingCostGroupProvinceException } from '../types/shippingCostGroup'
+import { useT } from '../i18n/useT'
+import { formatIrtPrice } from '../utils/irtPrice'
+import { StaticShippingExceptionModal } from './StaticShippingExceptionModal'
+import { ConfirmDeleteModal } from './ConfirmDeleteModal'
+import { Tooltip } from './Tooltip'
+import styles from './ShippingGroupExceptionsModal.module.css'
+
+interface ShippingGroupExceptionsModalProps {
+ open: boolean
+ onClose: () => void
+ groupName: string
+ provinces: CityOption[]
+ exceptions: ShippingCostGroupProvinceException[]
+ isSubmitting?: boolean
+ onSave: (items: Array<{ provinceId: string; amount: number }>) => Promise | void
+}
+
+const ANIMATION_MS = 220
+
+export function ShippingGroupExceptionsModal({
+ open,
+ onClose,
+ groupName,
+ provinces,
+ exceptions,
+ isSubmitting = false,
+ onSave,
+}: ShippingGroupExceptionsModalProps) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const [mounted, setMounted] = useState(open)
+ const [closing, setClosing] = useState(false)
+ const [items, setItems] = useState([])
+ const [addOpen, setAddOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleteTarget, setDeleteTarget] =
+ useState(null)
+ const [saving, setSaving] = useState(false)
+
+ const provinceNameById = useMemo(() => {
+ const map = new Map()
+ for (const province of provinces) {
+ map.set(
+ province.id,
+ isFa
+ ? province.nameFa || province.nameEn
+ : province.nameEn || province.nameFa,
+ )
+ }
+ return map
+ }, [provinces, isFa])
+
+ useEffect(() => {
+ if (open) {
+ setMounted(true)
+ setClosing(false)
+ setAddOpen(false)
+ setEditing(null)
+ setDeleteTarget(null)
+ } else if (mounted) {
+ setClosing(true)
+ const timer = setTimeout(() => {
+ setMounted(false)
+ setClosing(false)
+ }, ANIMATION_MS)
+ return () => clearTimeout(timer)
+ }
+ }, [open, mounted])
+
+ useEffect(() => {
+ if (open) setItems(exceptions)
+ }, [open, exceptions])
+
+ useEffect(() => {
+ if (!mounted || closing) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !saving && !isSubmitting && !addOpen && !deleteTarget) {
+ onClose()
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [mounted, closing, onClose, saving, isSubmitting, addOpen, deleteTarget])
+
+ if (!mounted) return null
+
+ async function persist(next: Array<{ provinceId: string; amount: number }>) {
+ setSaving(true)
+ try {
+ await onSave(next)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleUpsert(data: { provinceId: string; amount: number }) {
+ const next = editing
+ ? items.map((item) =>
+ item.provinceId === editing.provinceId
+ ? { ...item, provinceId: data.provinceId, amount: data.amount }
+ : item,
+ )
+ : [
+ ...items.filter((item) => item.provinceId !== data.provinceId),
+ {
+ id: `tmp-${data.provinceId}`,
+ provinceId: data.provinceId,
+ amount: data.amount,
+ },
+ ]
+
+ await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
+ setAddOpen(false)
+ setEditing(null)
+ }
+
+ async function handleDelete() {
+ if (!deleteTarget) return
+ const next = items.filter((item) => item.provinceId !== deleteTarget.provinceId)
+ await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
+ setDeleteTarget(null)
+ }
+
+ const busy = saving || isSubmitting
+
+ return createPortal(
+ !busy && !addOpen && !deleteTarget && onClose()}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="group-exceptions-modal-title"
+ lang={isFa ? 'fa' : 'en'}
+ dir={isFa ? 'rtl' : 'ltr'}
+ >
+
+
+
+ {t('store.shipping.groupException.title')}
+
+
+ {t('store.shipping.groupException.subtitle', { name: groupName })}
+
+
+
+
+
+
+
+
+
+
+
+ {items.length === 0 ? (
+
{t('store.shipping.groupException.empty')}
+ ) : (
+
+ {items.map((item) => (
+ -
+
+ {provinceNameById.get(item.provinceId) ?? item.provinceId}
+
+ {formatIrtPrice(item.amount)}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
item.provinceId)}
+ editing={
+ editing
+ ? { provinceId: editing.provinceId, amount: editing.amount }
+ : null
+ }
+ isSubmitting={busy}
+ onClose={() => {
+ if (!busy) {
+ setAddOpen(false)
+ setEditing(null)
+ }
+ }}
+ onSubmit={(data) => void handleUpsert(data)}
+ />
+
+ void handleDelete()}
+ onCancel={() => !busy && setDeleteTarget(null)}
+ />
+ ,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/StaticShippingExceptionModal.module.css b/apps/business/src/components/StaticShippingExceptionModal.module.css
new file mode 100644
index 0000000..9915474
--- /dev/null
+++ b/apps/business/src/components/StaticShippingExceptionModal.module.css
@@ -0,0 +1,181 @@
+.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: 260;
+ padding: 20px;
+}
+
+.modal {
+ width: 100%;
+ max-width: 440px;
+ background: var(--surface);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px 24px 0;
+}
+
+.title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.closeBtn {
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ color: var(--text-secondary);
+ transition: background 0.2s, color 0.2s;
+}
+
+.closeBtn:hover:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.form {
+ padding: 20px 24px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.error {
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ color: #b91c1c;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.field input,
+.field select {
+ min-height: var(--field-height);
+ padding: var(--field-padding-y) var(--field-padding-x);
+ font-size: var(--field-font-size);
+ line-height: 1.4;
+ color: var(--text-primary);
+ background: var(--surface);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ outline: none;
+ box-sizing: border-box;
+}
+
+.field input:focus,
+.field select:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
+.amountRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.amountRow input {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--font-en), var(--font-ui);
+}
+
+.amountUnit {
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 4px;
+ direction: rtl;
+}
+
+.cancelBtn {
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ transition: background 0.2s;
+}
+
+.cancelBtn:hover:not(:disabled) {
+ background: rgba(148, 163, 184, 0.15);
+}
+
+.submitBtn {
+ padding: 8px 18px;
+ font-size: 13px;
+ font-weight: 600;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
+}
+
+.submitBtn:disabled,
+.cancelBtn:disabled,
+.closeBtn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
+.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
+.modalIn { animation: modalFadeIn 0.25s ease forwards; }
+.modalOut { animation: modalFadeOut 0.22s ease forwards; }
+
+@keyframes overlayFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes overlayFadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes modalFadeIn {
+ from { opacity: 0; transform: translateY(12px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes modalFadeOut {
+ from { opacity: 1; transform: translateY(0) scale(1); }
+ to { opacity: 0; transform: translateY(8px) scale(0.98); }
+}
diff --git a/apps/business/src/components/StaticShippingExceptionModal.tsx b/apps/business/src/components/StaticShippingExceptionModal.tsx
new file mode 100644
index 0000000..ca601b9
--- /dev/null
+++ b/apps/business/src/components/StaticShippingExceptionModal.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import type { CityOption } from '../services/citiesService'
+import { useT } from '../i18n/useT'
+import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
+import styles from './StaticShippingExceptionModal.module.css'
+
+interface StaticShippingExceptionModalProps {
+ open: boolean
+ onClose: () => void
+ onSubmit: (data: { provinceId: string; amount: number }) => void
+ provinces: CityOption[]
+ usedProvinceIds: string[]
+ editing?: { provinceId: string; amount: number } | null
+ isSubmitting?: boolean
+}
+
+const ANIMATION_MS = 220
+
+export function StaticShippingExceptionModal({
+ open,
+ onClose,
+ onSubmit,
+ provinces,
+ usedProvinceIds,
+ editing = null,
+ isSubmitting = false,
+}: StaticShippingExceptionModalProps) {
+ const t = useT()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+ const [mounted, setMounted] = useState(open)
+ const [closing, setClosing] = useState(false)
+ const [provinceId, setProvinceId] = useState('')
+ const [amount, setAmount] = useState('')
+ const [formError, setFormError] = useState('')
+
+ const availableProvinces = useMemo(() => {
+ return provinces.filter(
+ (province) =>
+ province.id === editing?.provinceId ||
+ !usedProvinceIds.includes(province.id),
+ )
+ }, [provinces, usedProvinceIds, editing?.provinceId])
+
+ useEffect(() => {
+ if (open) {
+ setMounted(true)
+ setClosing(false)
+ setProvinceId(editing?.provinceId ?? '')
+ setAmount(
+ editing
+ ? formatIrtInput(String(Math.round(editing.amount)))
+ : '',
+ )
+ setFormError('')
+ } else if (mounted) {
+ setClosing(true)
+ const timer = setTimeout(() => {
+ setMounted(false)
+ setClosing(false)
+ }, ANIMATION_MS)
+ return () => clearTimeout(timer)
+ }
+ }, [open, editing, mounted])
+
+ useEffect(() => {
+ if (!mounted || closing) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !isSubmitting) onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [mounted, closing, onClose, isSubmitting])
+
+ if (!mounted) return null
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setFormError('')
+ const parsedAmount = parseIrtInput(amount)
+ if (!provinceId) {
+ setFormError(t('store.shipping.static.exception.errorProvince'))
+ return
+ }
+ if (parsedAmount === null || parsedAmount < 0) {
+ setFormError(t('store.shipping.form.errorAmount'))
+ return
+ }
+ onSubmit({ provinceId, amount: parsedAmount })
+ }
+
+ return createPortal(
+ !isSubmitting && onClose()}
+ >
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="static-exception-modal-title"
+ lang={isFa ? 'fa' : 'en'}
+ dir={isFa ? 'rtl' : 'ltr'}
+ >
+
+
+ {editing
+ ? t('store.shipping.static.exception.editTitle')
+ : t('store.shipping.static.exception.addTitle')}
+
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts
index 3801be1..6b1d557 100644
--- a/apps/business/src/i18n/messages.ts
+++ b/apps/business/src/i18n/messages.ts
@@ -924,6 +924,105 @@ const en = {
'store.card.installments.link': 'Manage installments',
'store.card.settings.desc': 'Configure store preferences, pages and themes.',
'store.card.settings.link': 'View settings',
+ 'store.shipping.page.subtitle':
+ 'Set a flat shipping fee for all orders, or define cost groups for categories.',
+ 'store.shipping.loading': 'Loading shipping settings…',
+ 'store.shipping.empty': 'No shipping cost groups yet. Add your first one.',
+ 'store.shipping.add': 'Add shipping cost group',
+ 'store.shipping.errorLoad': 'Failed to load shipping settings.',
+ 'store.shipping.errorSaveStatic': 'Failed to save static shipping fee.',
+ 'store.shipping.errorDelete': 'Failed to delete shipping cost group.',
+ 'store.shipping.deleteTitle': 'Delete shipping cost group',
+ 'store.shipping.deleteMessage':
+ 'Are you sure you want to delete "{name}"? This action cannot be undone.',
+ 'store.shipping.toast.staticSaved': 'Static shipping fee saved.',
+ 'store.shipping.toast.created': 'Shipping cost group created.',
+ 'store.shipping.toast.updated': 'Shipping cost group updated.',
+ 'store.shipping.toast.deleted': 'Shipping cost group deleted.',
+ 'store.shipping.tooltip.edit': 'Edit',
+ 'store.shipping.tooltip.remove': 'Delete',
+ 'store.shipping.tooltip.exceptions': 'Province exceptions',
+ 'store.shipping.tooltip.categories': 'Assign categories',
+ 'store.shipping.row.defaultAmount': 'Default amount',
+ 'store.shipping.groupException.title': 'Province exceptions',
+ 'store.shipping.groupException.subtitle': 'Special rates for "{name}"',
+ 'store.shipping.groupException.empty': 'No province exceptions yet.',
+ 'store.shipping.groupException.add': 'Add province exception',
+ 'store.shipping.groupException.deleteTitle': 'Delete province exception',
+ 'store.shipping.groupException.deleteMessage':
+ 'Remove the special shipping fee for "{name}"?',
+ 'store.shipping.groupException.toast.saved': 'Province exceptions saved.',
+ 'store.shipping.groupException.errorSave': 'Failed to save province exceptions.',
+ 'store.shipping.groupCategories.title': 'Assign categories',
+ 'store.shipping.groupCategories.subtitle': 'Categories using "{name}"',
+ 'store.shipping.groupCategories.loading': 'Loading categories…',
+ 'store.shipping.groupCategories.save': 'Save categories',
+ 'store.shipping.groupCategories.toast.saved': 'Categories assigned.',
+ 'store.shipping.groupCategories.errorLoad': 'Failed to load categories.',
+ 'store.shipping.groupCategories.errorSave': 'Failed to save categories.',
+ 'store.shipping.static.title': 'Static shipping fee for all orders',
+ 'store.shipping.static.desc':
+ 'When enabled, every order uses this flat fee and shipping cost groups are ignored. Zero means free shipping.',
+ 'store.shipping.static.amount': 'Amount (IRT)',
+ 'store.shipping.static.amountPlaceholder': 'e.g. 150,000',
+ 'store.shipping.static.exception.title': 'Province exceptions',
+ 'store.shipping.static.exception.desc':
+ 'Override the flat fee for specific provinces.',
+ 'store.shipping.static.exception.disabledHint':
+ 'Enable the static shipping fee to manage province exceptions.',
+ 'store.shipping.static.exception.empty': 'No province exceptions yet.',
+ 'store.shipping.static.exception.add': 'Add province exception',
+ 'store.shipping.static.exception.addTitle': 'Add province exception',
+ 'store.shipping.static.exception.editTitle': 'Edit province exception',
+ 'store.shipping.static.exception.province': 'Province',
+ 'store.shipping.static.exception.provincePlaceholder': 'Select a province',
+ 'store.shipping.static.exception.amount': 'Amount (IRT)',
+ 'store.shipping.static.exception.errorProvince': 'Select a province.',
+ 'store.shipping.static.exception.deleteTitle': 'Delete province exception',
+ 'store.shipping.static.exception.deleteMessage':
+ 'Remove the special shipping fee for "{name}"?',
+ 'store.shipping.groups.title': 'Shipping cost groups',
+ 'store.shipping.groups.desc':
+ 'Create cost groups with shipping methods and a default fee. Assign them to categories later for checkout.',
+ 'store.shipping.groups.disabledHint':
+ 'Turn this on to use cost groups. The static shipping fee will be disabled.',
+ 'store.shipping.form.addTitle': 'Add shipping cost group',
+ 'store.shipping.form.editTitle': 'Edit shipping cost group',
+ 'store.shipping.form.nameFa': 'Name (FA)',
+ 'store.shipping.form.nameEn': 'Name (EN)',
+ 'store.shipping.form.nameFaPlaceholder': 'مثلاً گروه لوازم خانگی',
+ 'store.shipping.form.nameEnPlaceholder': 'e.g. Home appliances group',
+ 'store.shipping.form.description': 'Description',
+ 'store.shipping.form.descriptionPlaceholder': 'Notes for your team…',
+ 'store.shipping.form.categories': 'Assigned categories',
+ 'store.shipping.form.categoriesHint':
+ 'Products in these categories use this shipping group.',
+ 'store.shipping.form.categoriesSearch': 'Type 3+ characters to search categories',
+ 'store.shipping.form.categoriesLoading': 'Loading categories…',
+ 'store.shipping.form.errorCategoriesLoad': 'Failed to load categories.',
+ 'store.shipping.form.methods': 'Available shipping methods',
+ 'store.shipping.form.methodsHint': 'Select the couriers customers can choose for this group.',
+ 'store.shipping.form.defaultAmount': 'Default amount (IRT)',
+ 'store.shipping.form.defaultAmountHint':
+ 'Applied to all provinces unless a city-specific amount is set later.',
+ 'store.shipping.form.defaultAmountPlaceholder': 'e.g. 120,000',
+ 'store.shipping.form.isDefault': 'Make this the default group',
+ 'store.shipping.form.isDefaultHint':
+ 'Categories with no assigned shipping group will use this group.',
+ 'store.shipping.row.defaultBadge': 'Default',
+ 'store.shipping.form.cancel': 'Cancel',
+ 'store.shipping.form.save': 'Save',
+ 'store.shipping.form.update': 'Update',
+ 'store.shipping.form.saving': 'Saving…',
+ 'store.shipping.form.errorRequired': 'Please fill in all required fields.',
+ 'store.shipping.form.errorMethods': 'Select at least one shipping method.',
+ 'store.shipping.form.errorAmount': 'Enter a valid amount in IRT.',
+ 'store.shipping.form.errorSave': 'Failed to save shipping cost group.',
+ 'store.shipping.method.post': 'Post',
+ 'store.shipping.method.tipax': 'Tipax',
+ 'store.shipping.method.mahex': 'Mahex',
+ 'store.shipping.method.freight': 'Freight',
+ 'store.shipping.method.air': 'Air shipping',
'store.installments.page.subtitle':
'Create loan and installment methods your store can offer customers.',
'store.installments.loading': 'Loading installment methods…',
@@ -1186,6 +1285,12 @@ const en = {
'orderItems.item': 'item',
'orderItems.itemsPlural': 'items',
'orderItems.close': 'Close',
+ 'orderItems.delivery': 'Delivery',
+ 'orderItems.pickup': 'In-store pickup',
+ 'orderItems.subtotal': 'Subtotal',
+ 'orderItems.shipping': 'Shipping',
+ 'orderItems.discount': 'Discount',
+ 'orderItems.freeShipping': 'Free shipping',
'orderTransactions.title': 'Transaction details',
'orderTransactions.empty': 'No payment transactions recorded for this order.',
@@ -2363,6 +2468,7 @@ const en = {
'title.storeItems': 'My Store Items',
'title.orders': 'My Orders',
'title.shoppingCards': 'Shopping Cards',
+ 'title.shipping': 'Shipping Fees',
'title.installments': 'Installments & Facilities',
'title.addInstallment': 'Add Installment Method',
'title.editInstallment': 'Edit Installment Method',
@@ -3691,6 +3797,105 @@ const fa: Record = {
'store.card.installments.link': 'مدیریت اقساط',
'store.card.settings.desc': 'ترجیحات، صفحات و ظاهر فروشگاه را پیکربندی کنید.',
'store.card.settings.link': 'مشاهده تنظیمات',
+ 'store.shipping.page.subtitle':
+ 'یک هزینه ارسال ثابت برای همه سفارشها تنظیم کنید، یا گروههای هزینه برای دستهها تعریف کنید.',
+ 'store.shipping.loading': 'در حال بارگذاری تنظیمات ارسال…',
+ 'store.shipping.empty': 'هنوز گروه هزینه ارسالی تعریف نشده. اولین مورد را اضافه کنید.',
+ 'store.shipping.add': 'افزودن گروه هزینه ارسال',
+ 'store.shipping.errorLoad': 'بارگذاری تنظیمات ارسال ناموفق بود.',
+ 'store.shipping.errorSaveStatic': 'ذخیره هزینه ارسال ثابت ناموفق بود.',
+ 'store.shipping.errorDelete': 'حذف گروه هزینه ارسال ناموفق بود.',
+ 'store.shipping.deleteTitle': 'حذف گروه هزینه ارسال',
+ 'store.shipping.deleteMessage':
+ 'از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
+ 'store.shipping.toast.staticSaved': 'هزینه ارسال ثابت ذخیره شد.',
+ 'store.shipping.toast.created': 'گروه هزینه ارسال ایجاد شد.',
+ 'store.shipping.toast.updated': 'گروه هزینه ارسال بهروزرسانی شد.',
+ 'store.shipping.toast.deleted': 'گروه هزینه ارسال حذف شد.',
+ 'store.shipping.tooltip.edit': 'ویرایش',
+ 'store.shipping.tooltip.remove': 'حذف',
+ 'store.shipping.tooltip.exceptions': 'استثناهای استانی',
+ 'store.shipping.tooltip.categories': 'اختصاص دستهبندی',
+ 'store.shipping.row.defaultAmount': 'مبلغ پیشفرض',
+ 'store.shipping.groupException.title': 'استثناهای استانی',
+ 'store.shipping.groupException.subtitle': 'نرخهای ویژه برای «{name}»',
+ 'store.shipping.groupException.empty': 'هنوز استثنای استانی تعریف نشده.',
+ 'store.shipping.groupException.add': 'افزودن استثنای استان',
+ 'store.shipping.groupException.deleteTitle': 'حذف استثنای استان',
+ 'store.shipping.groupException.deleteMessage':
+ 'هزینه ارسال ویژه «{name}» حذف شود؟',
+ 'store.shipping.groupException.toast.saved': 'استثناهای استانی ذخیره شد.',
+ 'store.shipping.groupException.errorSave': 'ذخیره استثناهای استانی ناموفق بود.',
+ 'store.shipping.groupCategories.title': 'اختصاص دستهبندی',
+ 'store.shipping.groupCategories.subtitle': 'دستههایی که از «{name}» استفاده میکنند',
+ 'store.shipping.groupCategories.loading': 'در حال بارگذاری دستهبندیها…',
+ 'store.shipping.groupCategories.save': 'ذخیره دستهبندیها',
+ 'store.shipping.groupCategories.toast.saved': 'دستهبندیها اختصاص داده شد.',
+ 'store.shipping.groupCategories.errorLoad': 'بارگذاری دستهبندیها ناموفق بود.',
+ 'store.shipping.groupCategories.errorSave': 'ذخیره دستهبندیها ناموفق بود.',
+ 'store.shipping.static.title': 'هزینه ارسال ثابت برای همه سفارشها',
+ 'store.shipping.static.desc':
+ 'با فعالسازی، همه سفارشها همین مبلغ ثابت را میگیرند و گروههای هزینه نادیده گرفته میشوند. عدد صفر معادل با هزینه ارسال رایگان میباشد.',
+ 'store.shipping.static.amount': 'مبلغ (IRT)',
+ 'store.shipping.static.amountPlaceholder': 'مثلاً ۱۵۰,۰۰۰',
+ 'store.shipping.static.exception.title': 'استثناهای استانی',
+ 'store.shipping.static.exception.desc':
+ 'برای استانهای خاص مبلغ متفاوتی از هزینه ثابت تعریف کنید.',
+ 'store.shipping.static.exception.disabledHint':
+ 'برای مدیریت استثناهای استانی، هزینه ارسال ثابت را فعال کنید.',
+ 'store.shipping.static.exception.empty': 'هنوز استثنای استانی تعریف نشده.',
+ 'store.shipping.static.exception.add': 'افزودن استثنای استان',
+ 'store.shipping.static.exception.addTitle': 'افزودن استثنای استان',
+ 'store.shipping.static.exception.editTitle': 'ویرایش استثنای استان',
+ 'store.shipping.static.exception.province': 'استان',
+ 'store.shipping.static.exception.provincePlaceholder': 'یک استان انتخاب کنید',
+ 'store.shipping.static.exception.amount': 'مبلغ (IRT)',
+ 'store.shipping.static.exception.errorProvince': 'یک استان انتخاب کنید.',
+ 'store.shipping.static.exception.deleteTitle': 'حذف استثنای استان',
+ 'store.shipping.static.exception.deleteMessage':
+ 'هزینه ارسال ویژه «{name}» حذف شود؟',
+ 'store.shipping.groups.title': 'گروههای هزینه ارسال',
+ 'store.shipping.groups.desc':
+ 'گروههایی با روشهای ارسال و مبلغ پیشفرض بسازید. بعداً میتوانید آنها را به دستهها اختصاص دهید.',
+ 'store.shipping.groups.disabledHint':
+ 'برای استفاده از گروههای هزینه این گزینه را فعال کنید. هزینه ارسال ثابت غیرفعال میشود.',
+ 'store.shipping.form.addTitle': 'افزودن گروه هزینه ارسال',
+ 'store.shipping.form.editTitle': 'ویرایش گروه هزینه ارسال',
+ 'store.shipping.form.nameFa': 'نام فارسی',
+ 'store.shipping.form.nameEn': 'نام انگلیسی',
+ 'store.shipping.form.nameFaPlaceholder': 'مثلاً گروه لوازم خانگی',
+ 'store.shipping.form.nameEnPlaceholder': 'e.g. Home appliances group',
+ 'store.shipping.form.description': 'توضیحات',
+ 'store.shipping.form.descriptionPlaceholder': 'یادداشت برای تیم شما…',
+ 'store.shipping.form.categories': 'دستهبندیهای اختصاصیافته',
+ 'store.shipping.form.categoriesHint':
+ 'محصولات این دستهها از این گروه هزینه ارسال استفاده میکنند.',
+ 'store.shipping.form.categoriesSearch': 'حداقل ۳ حرف برای جستجوی دستهبندی تایپ کنید',
+ 'store.shipping.form.categoriesLoading': 'در حال بارگذاری دستهبندیها…',
+ 'store.shipping.form.errorCategoriesLoad': 'بارگذاری دستهبندیها ناموفق بود.',
+ 'store.shipping.form.methods': 'روشهای ارسال فعال',
+ 'store.shipping.form.methodsHint': 'پیکهایی که مشتری میتواند برای این گروه انتخاب کند.',
+ 'store.shipping.form.defaultAmount': 'مبلغ پیشفرض (IRT)',
+ 'store.shipping.form.defaultAmountHint':
+ 'برای همه استانها اعمال میشود مگر اینکه برای شهر خاصی مبلغ دیگری تعیین شود.',
+ 'store.shipping.form.defaultAmountPlaceholder': 'مثلاً ۱۲۰,۰۰۰',
+ 'store.shipping.form.isDefault': 'این گروه را بهعنوان گروه پیشفرض تنظیم کن',
+ 'store.shipping.form.isDefaultHint':
+ 'دستههایی که گروه ارسال اختصاصی ندارند از این گروه استفاده میکنند.',
+ 'store.shipping.row.defaultBadge': 'پیشفرض',
+ 'store.shipping.form.cancel': 'انصراف',
+ 'store.shipping.form.save': 'ذخیره',
+ 'store.shipping.form.update': 'بهروزرسانی',
+ 'store.shipping.form.saving': 'در حال ذخیره…',
+ 'store.shipping.form.errorRequired': 'لطفاً همه فیلدهای الزامی را پر کنید.',
+ 'store.shipping.form.errorMethods': 'حداقل یک روش ارسال انتخاب کنید.',
+ 'store.shipping.form.errorAmount': 'یک مبلغ معتبر به IRT وارد کنید.',
+ 'store.shipping.form.errorSave': 'ذخیره گروه هزینه ارسال ناموفق بود.',
+ 'store.shipping.method.post': 'پست',
+ 'store.shipping.method.tipax': 'تیپاکس',
+ 'store.shipping.method.mahex': 'ماهکس',
+ 'store.shipping.method.freight': 'باربری',
+ 'store.shipping.method.air': 'ارسال هوایی',
'store.installments.page.subtitle':
'روشهای وام و اقساطی که فروشگاه به مشتریان ارائه میدهد را بسازید.',
'store.installments.loading': 'در حال بارگذاری روشهای اقساط…',
@@ -3953,6 +4158,12 @@ const fa: Record = {
'orderItems.item': 'قلم',
'orderItems.itemsPlural': 'قلم',
'orderItems.close': 'بستن',
+ 'orderItems.delivery': 'ارسال',
+ 'orderItems.pickup': 'تحویل حضوری',
+ 'orderItems.subtotal': 'جمع جزء',
+ 'orderItems.shipping': 'هزینه ارسال',
+ 'orderItems.discount': 'تخفیف',
+ 'orderItems.freeShipping': 'ارسال رایگان',
'orderTransactions.title': 'جزئیات تراکنش',
'orderTransactions.empty': 'تراکنش پرداختی برای این سفارش ثبت نشده است.',
@@ -5132,6 +5343,7 @@ const fa: Record = {
'title.storeItems': 'محصولات فروشگاه',
'title.orders': 'سفارشهای من',
'title.shoppingCards': 'کارتهای خرید',
+ 'title.shipping': 'هزینههای ارسال',
'title.installments': 'اقساط و تسهیلات',
'title.addInstallment': 'افزودن روش اقساط',
'title.editInstallment': 'ویرایش روش اقساط',
@@ -5654,6 +5866,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/products', labels: [t('title.products')] },
{ match: '/store/items', labels: [t('title.store'), t('title.storeItems')] },
{ match: '/store/orders', labels: [t('title.store'), t('title.orders')] },
+ { match: '/store/shipping', labels: [t('title.store'), t('title.shipping')] },
{ match: '/store/cards', labels: [t('title.store'), t('title.shoppingCards')] },
{
match: '/store/installments/new',
diff --git a/apps/business/src/pages/StoreShippingPage.module.css b/apps/business/src/pages/StoreShippingPage.module.css
new file mode 100644
index 0000000..4d47815
--- /dev/null
+++ b/apps/business/src/pages/StoreShippingPage.module.css
@@ -0,0 +1,326 @@
+.methodPanel {
+ margin-bottom: 0;
+}
+
+.methodTopRow {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.methodBody {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ transition: opacity 0.2s, filter 0.2s;
+}
+
+.methodHeading {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px 10px;
+}
+
+.methodTitle {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.methodDesc {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--text-secondary);
+}
+
+.methodInactive {
+ opacity: 0.45;
+ filter: blur(0.6px) grayscale(0.2);
+ pointer-events: none;
+ user-select: none;
+}
+
+.amountInline {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ direction: ltr;
+}
+
+.amountInput {
+ width: 140px;
+ min-height: var(--field-height);
+ padding: var(--field-padding-y) var(--field-padding-x);
+ font-size: var(--field-font-size);
+ line-height: 1.4;
+ color: var(--text-primary);
+ background: var(--surface);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ outline: none;
+ box-sizing: border-box;
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+.amountInput:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
+.amountInput:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.amountUnit {
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+}
+
+.amountSaveBtn {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-inline-start: auto;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
+ transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
+}
+
+.amountSaveBtn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
+}
+
+.amountSaveBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.exceptionsBlock {
+ margin-top: 18px;
+ padding-top: 16px;
+ border-top: 1px solid rgba(148, 163, 184, 0.2);
+ transition: opacity 0.2s, filter 0.2s;
+}
+
+.exceptionsHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.exceptionsHeading {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px 10px;
+}
+
+.exceptionsTitle {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+}
+
+.exceptionsDesc {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--text-secondary);
+}
+
+.exceptionAddBtn {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: 8px;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
+ transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
+}
+
+.exceptionAddBtn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
+}
+
+.exceptionAddBtn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.exceptionsEmpty {
+ margin: 0;
+ padding: 12px 0 2px;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.exceptionsList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.exceptionRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.14);
+}
+
+.exceptionRow:last-child {
+ border-bottom: none;
+}
+
+.exceptionProvince {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.exceptionAmount {
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-en), var(--font-ui);
+ direction: ltr;
+ unicode-bidi: isolate;
+}
+
+.exceptionControls {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.exceptionControlBtn {
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: var(--text-secondary);
+ transition: background 0.15s, color 0.15s;
+}
+
+.exceptionControlBtn:hover:not(:disabled) {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
+.exceptionControlBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.exceptionDanger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.12);
+ color: #ef4444;
+}
+
+.sectionDivider {
+ height: 1px;
+ margin: 28px 0;
+ background: linear-gradient(
+ to left,
+ transparent,
+ rgba(148, 163, 184, 0.45) 12%,
+ rgba(148, 163, 184, 0.45) 88%,
+ transparent
+ );
+}
+
+.groupsContent {
+ margin-top: 16px;
+ padding-top: 4px;
+ transition: opacity 0.2s, filter 0.2s;
+}
+
+.groupsList {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+@media (max-width: 900px) {
+ .methodTopRow {
+ align-items: flex-start;
+ }
+
+ .methodBody {
+ flex-wrap: wrap;
+ }
+
+ .amountInline {
+ width: 100%;
+ }
+
+ .amountInput {
+ flex: 1;
+ width: auto;
+ }
+
+ .amountSaveBtn {
+ margin-inline-start: 0;
+ }
+}
diff --git a/apps/business/src/pages/StoreShippingPage.tsx b/apps/business/src/pages/StoreShippingPage.tsx
new file mode 100644
index 0000000..a8f36c0
--- /dev/null
+++ b/apps/business/src/pages/StoreShippingPage.tsx
@@ -0,0 +1,701 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Check, Pencil, Plus, Trash2, Truck } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import { Breadcrumbs } from '../components/Breadcrumbs'
+import { EmptyState } from '../components/EmptyState'
+import { PageTitle } from '../components/PageTitle'
+import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
+import { ShippingCostGroupModal } from '../components/ShippingCostGroupModal'
+import { ShippingCostGroupRow } from '../components/ShippingCostGroupRow'
+import { ShippingGroupCategoriesModal } from '../components/ShippingGroupCategoriesModal'
+import { ShippingGroupExceptionsModal } from '../components/ShippingGroupExceptionsModal'
+import { StaticShippingExceptionModal } from '../components/StaticShippingExceptionModal'
+import { Switch } from '../components/Switch'
+import { Tooltip } from '../components/Tooltip'
+import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
+import { ApiError } from '../lib/api'
+import {
+ getSettings,
+ updateStoreSettings,
+ type StaticShippingProvinceException,
+ type StoreSettings,
+} from '../services/settingsService'
+import {
+ createShippingCostGroup,
+ deleteShippingCostGroup,
+ getGroupProvinceExceptions,
+ listAllShippingCostGroups,
+ updateShippingCostGroup,
+} from '../services/shippingCostGroupService'
+import {
+ listIranProvinces,
+ type CityOption,
+} from '../services/citiesService'
+import type {
+ ShippingCostGroup,
+ ShippingCostGroupFormPayload,
+} from '../types/shippingCostGroup'
+import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
+import pageStyles from '../components/PageContent.module.css'
+import panelStyles from './ProductSettingsPage.module.css'
+import listStyles from './BrandsPage.module.css'
+import styles from './StoreShippingPage.module.css'
+
+function sortShippingCostGroups(items: ShippingCostGroup[]) {
+ return [...items].sort((a, b) => {
+ if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
+ if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
+ return b.createdAt.localeCompare(a.createdAt)
+ })
+}
+
+function upsertShippingCostGroup(
+ items: ShippingCostGroup[],
+ next: ShippingCostGroup,
+) {
+ const without = items.filter((item) => item.id !== next.id)
+ const cleared = next.isDefault
+ ? without.map((item) => (item.isDefault ? { ...item, isDefault: false } : item))
+ : without
+ return sortShippingCostGroups([...cleared, next])
+}
+
+export function StoreShippingPage() {
+ const t = useT()
+ const { showToast } = useToast()
+ const { locale } = useLocale()
+ const isFa = locale === 'fa'
+
+ const [staticEnabled, setStaticEnabled] = useState(true)
+ const [staticAmount, setStaticAmount] = useState('')
+ const [exceptions, setExceptions] = useState([])
+ const [provinces, setProvinces] = useState([])
+ const [groups, setGroups] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [savingStatic, setSavingStatic] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState('')
+ const [modalOpen, setModalOpen] = useState(false)
+ const [editingGroup, setEditingGroup] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [exceptionModalOpen, setExceptionModalOpen] = useState(false)
+ const [editingException, setEditingException] =
+ useState(null)
+ const [deleteExceptionTarget, setDeleteExceptionTarget] =
+ useState(null)
+ const [groupExceptionsTarget, setGroupExceptionsTarget] =
+ useState(null)
+ const [groupCategoriesTarget, setGroupCategoriesTarget] =
+ useState(null)
+
+ const groupsEnabled = !staticEnabled
+ const groupsDisabled = !groupsEnabled
+ const exceptionsDisabled = !staticEnabled
+ const staticInactive = !staticEnabled
+ const groupsInactive = !groupsEnabled
+
+ const provinceNameById = useMemo(() => {
+ const map = new Map()
+ for (const province of provinces) {
+ map.set(
+ province.id,
+ isFa
+ ? province.nameFa || province.nameEn
+ : province.nameEn || province.nameFa,
+ )
+ }
+ return map
+ }, [provinces, isFa])
+
+ useEffect(() => {
+ const controller = new AbortController()
+ void loadPage(controller.signal)
+ return () => controller.abort()
+ }, [])
+
+ async function loadPage(signal?: AbortSignal) {
+ setIsLoading(true)
+ setError('')
+ try {
+ const [settingsRes, groupItems, provinceItems] = await Promise.all([
+ getSettings(signal),
+ listAllShippingCostGroups(signal),
+ listIranProvinces(signal),
+ ])
+ const store = settingsRes.settings.store
+ setStaticEnabled(Boolean(store.staticShippingEnabled))
+ setStaticAmount(
+ formatIrtInput(String(Math.round(Number(store.staticShippingAmount ?? 0)))),
+ )
+ setExceptions(store.staticShippingProvinceExceptions ?? [])
+ setProvinces(provinceItems)
+ setGroups(sortShippingCostGroups(groupItems))
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.errorLoad'))
+ }
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function persistStaticShipping(next: {
+ enabled: boolean
+ amount: number
+ provinceExceptions?: StaticShippingProvinceException[]
+ }) {
+ setSavingStatic(true)
+ setError('')
+ try {
+ const patch: Partial = {
+ staticShippingEnabled: next.enabled,
+ staticShippingAmount: next.amount,
+ staticShippingProvinceExceptions:
+ next.provinceExceptions ?? exceptions,
+ }
+ const data = await updateStoreSettings(patch)
+ const store = data.settings.store
+ setStaticEnabled(Boolean(store.staticShippingEnabled))
+ setStaticAmount(
+ formatIrtInput(String(Math.round(Number(store.staticShippingAmount ?? 0)))),
+ )
+ setExceptions(store.staticShippingProvinceExceptions ?? [])
+ showToast(t('store.shipping.toast.staticSaved'), 'success')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.errorSaveStatic'))
+ }
+ throw err
+ } finally {
+ setSavingStatic(false)
+ }
+ }
+
+ async function handleStaticToggle(checked: boolean) {
+ const amount = parseIrtInput(staticAmount) ?? 0
+ try {
+ await persistStaticShipping({ enabled: checked, amount })
+ } catch {
+ // error already surfaced
+ }
+ }
+
+ async function handleGroupsToggle(checked: boolean) {
+ const amount = parseIrtInput(staticAmount) ?? 0
+ try {
+ // Groups mode is the inverse of static shipping.
+ await persistStaticShipping({ enabled: !checked, amount })
+ } catch {
+ // error already surfaced
+ }
+ }
+
+ async function handleStaticAmountSave() {
+ if (!staticEnabled) return
+ const amount = parseIrtInput(staticAmount)
+ if (amount === null) {
+ setError(t('store.shipping.form.errorAmount'))
+ return
+ }
+ try {
+ await persistStaticShipping({ enabled: true, amount })
+ } catch {
+ // error already surfaced
+ }
+ }
+
+ function openCreateModal() {
+ if (groupsDisabled) return
+ setEditingGroup(null)
+ setModalOpen(true)
+ }
+
+ function openEditModal(id: string) {
+ if (groupsDisabled) return
+ const group = groups.find((item) => item.id === id) ?? null
+ setEditingGroup(group)
+ setModalOpen(true)
+ }
+
+ async function handleModalSubmit(payload: ShippingCostGroupFormPayload) {
+ setIsSubmitting(true)
+ setError('')
+ try {
+ if (editingGroup) {
+ const res = await updateShippingCostGroup(editingGroup.id, payload)
+ setGroups((prev) => upsertShippingCostGroup(prev, res.group))
+ showToast(t('store.shipping.toast.updated'), 'success')
+ } else {
+ const res = await createShippingCostGroup(payload)
+ setGroups((prev) => upsertShippingCostGroup(prev, res.group))
+ showToast(t('store.shipping.toast.created'), 'success')
+ }
+ setModalOpen(false)
+ setEditingGroup(null)
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.form.errorSave'))
+ }
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function confirmDelete() {
+ if (!deleteTarget) return
+ setIsSubmitting(true)
+ setError('')
+ try {
+ await deleteShippingCostGroup(deleteTarget.id)
+ setGroups((prev) => prev.filter((item) => item.id !== deleteTarget.id))
+ setDeleteTarget(null)
+ showToast(t('store.shipping.toast.deleted'), 'success')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.errorDelete'))
+ }
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function handleExceptionSubmit(data: {
+ provinceId: string
+ amount: number
+ }) {
+ const amount = parseIrtInput(staticAmount) ?? 0
+ const nextExceptions = editingException
+ ? exceptions.map((item) =>
+ item.provinceId === editingException.provinceId ? data : item,
+ )
+ : [...exceptions.filter((item) => item.provinceId !== data.provinceId), data]
+
+ setIsSubmitting(true)
+ try {
+ await persistStaticShipping({
+ enabled: staticEnabled,
+ amount,
+ provinceExceptions: nextExceptions,
+ })
+ setExceptionModalOpen(false)
+ setEditingException(null)
+ } catch {
+ // error already surfaced
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function confirmDeleteException() {
+ if (!deleteExceptionTarget) return
+ const amount = parseIrtInput(staticAmount) ?? 0
+ const nextExceptions = exceptions.filter(
+ (item) => item.provinceId !== deleteExceptionTarget.provinceId,
+ )
+ setIsSubmitting(true)
+ try {
+ await persistStaticShipping({
+ enabled: staticEnabled,
+ amount,
+ provinceExceptions: nextExceptions,
+ })
+ setDeleteExceptionTarget(null)
+ } catch {
+ // error already surfaced
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function handleGroupExceptionsSave(
+ items: Array<{ provinceId: string; amount: number }>,
+ ) {
+ if (!groupExceptionsTarget) return
+ setIsSubmitting(true)
+ setError('')
+ try {
+ const res = await updateShippingCostGroup(groupExceptionsTarget.id, {
+ provinceExceptions: items,
+ })
+ setGroups((prev) =>
+ prev.map((item) => (item.id === groupExceptionsTarget.id ? res.group : item)),
+ )
+ setGroupExceptionsTarget(res.group)
+ showToast(t('store.shipping.groupException.toast.saved'), 'success')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.groupException.errorSave'))
+ }
+ throw err
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ async function handleGroupCategoriesSave(categoryIds: string[]) {
+ if (!groupCategoriesTarget) return
+ setIsSubmitting(true)
+ setError('')
+ try {
+ const res = await updateShippingCostGroup(groupCategoriesTarget.id, {
+ categoryIds,
+ })
+ setGroups((prev) =>
+ prev.map((item) => (item.id === groupCategoriesTarget.id ? res.group : item)),
+ )
+ setGroupCategoriesTarget(res.group)
+ showToast(t('store.shipping.groupCategories.toast.saved'), 'success')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError(t('store.shipping.groupCategories.errorSave'))
+ }
+ throw err
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
{t('title.shipping')}
+
{t('store.shipping.page.subtitle')}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {isLoading ? (
+ {t('store.shipping.loading')}
+ ) : (
+ <>
+
+
void handleStaticToggle(checked)}
+ />
+
+
+
+
{t('store.shipping.static.desc')}
+
+
+
+ setStaticAmount(formatIrtInput(e.target.value))}
+ disabled={staticInactive || savingStatic}
+ aria-label={t('store.shipping.static.amount')}
+ />
+ IRT
+
+
+
+
+
+
+
+
+
+
+
+ {t('store.shipping.static.exception.title')}
+
+
+ {staticInactive
+ ? t('store.shipping.static.exception.disabledHint')
+ : t('store.shipping.static.exception.desc')}
+
+
+
+
+
+
+
+ {exceptions.length === 0 ? (
+
+ {t('store.shipping.static.exception.empty')}
+
+ ) : (
+
+ {exceptions.map((item) => (
+ -
+
+ {provinceNameById.get(item.provinceId) ?? item.provinceId}
+
+
+ {formatIrtPrice(item.amount)}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+ {isLoading ? (
+ {t('store.shipping.loading')}
+ ) : (
+ <>
+
+
void handleGroupsToggle(checked)}
+ />
+
+
+
+
+ {groupsInactive
+ ? t('store.shipping.groups.disabledHint')
+ : t('store.shipping.groups.desc')}
+
+
+
+
+
+
+
+
+
+ {groups.length === 0 ? (
+
+ ) : (
+
+ {groups.map((group) => (
+ {
+ const item = groups.find((entry) => entry.id === id)
+ if (item) setGroupCategoriesTarget(item)
+ }}
+ onExceptions={(id) => {
+ const item = groups.find((entry) => entry.id === id)
+ if (item) setGroupExceptionsTarget(item)
+ }}
+ onRemove={(id) => {
+ const item = groups.find((entry) => entry.id === id)
+ if (item) setDeleteTarget(item)
+ }}
+ />
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ {
+ if (!isSubmitting) {
+ setModalOpen(false)
+ setEditingGroup(null)
+ }
+ }}
+ onSubmit={(payload) => void handleModalSubmit(payload)}
+ />
+
+ !isSubmitting && setGroupExceptionsTarget(null)}
+ onSave={handleGroupExceptionsSave}
+ />
+
+ !isSubmitting && setGroupCategoriesTarget(null)}
+ onSave={handleGroupCategoriesSave}
+ />
+
+ item.provinceId)}
+ editing={editingException}
+ isSubmitting={isSubmitting || savingStatic}
+ onClose={() => {
+ if (!isSubmitting && !savingStatic) {
+ setExceptionModalOpen(false)
+ setEditingException(null)
+ }
+ }}
+ onSubmit={(data) => void handleExceptionSubmit(data)}
+ />
+
+ !isSubmitting && setDeleteTarget(null)}
+ />
+
+ !isSubmitting && setDeleteExceptionTarget(null)}
+ />
+
+ )
+}
diff --git a/apps/business/src/services/settingsService.ts b/apps/business/src/services/settingsService.ts
index 0af4518..fdd87f7 100644
--- a/apps/business/src/services/settingsService.ts
+++ b/apps/business/src/services/settingsService.ts
@@ -62,6 +62,14 @@ export interface StoreSettings {
torobEnabled: boolean
orderProcessSteps: OrderProcessStep[]
ePayment: EPaymentSettings
+ staticShippingEnabled?: boolean
+ staticShippingAmount?: number
+ staticShippingProvinceExceptions?: StaticShippingProvinceException[]
+}
+
+export interface StaticShippingProvinceException {
+ provinceId: string
+ amount: number
}
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
diff --git a/apps/business/src/services/shippingCostGroupService.ts b/apps/business/src/services/shippingCostGroupService.ts
new file mode 100644
index 0000000..f0ec78b
--- /dev/null
+++ b/apps/business/src/services/shippingCostGroupService.ts
@@ -0,0 +1,92 @@
+import { apiRequest } from '../lib/api'
+import { getActiveBusinessId } from '../lib/businessContext'
+import type {
+ ShippingCostGroup,
+ ShippingCostGroupFormPayload,
+ ShippingCostGroupsListResponse,
+} from '../types/shippingCostGroup'
+
+function businessPath(suffix = '') {
+ const businessId = getActiveBusinessId()
+ if (!businessId) {
+ throw new Error('No active business selected. Please sign in again.')
+ }
+ return `/businesses/${businessId}/shipping-cost-groups${suffix}`
+}
+
+export async function listShippingCostGroups(
+ page = 1,
+ pageSize = 100,
+ signal?: AbortSignal,
+) {
+ const params = new URLSearchParams({
+ page: String(page),
+ pageSize: String(pageSize),
+ })
+
+ return apiRequest(
+ `${businessPath()}?${params.toString()}`,
+ { auth: true, signal },
+ )
+}
+
+export async function listAllShippingCostGroups(signal?: AbortSignal) {
+ const pageSize = 100
+ let page = 1
+ let items: ShippingCostGroup[] = []
+ let total = Infinity
+
+ while (items.length < total) {
+ const data = await listShippingCostGroups(page, pageSize, signal)
+ items = items.concat(data.items)
+ total = data.total
+ page += 1
+ }
+
+ return items
+}
+
+export async function getShippingCostGroup(groupId: string, signal?: AbortSignal) {
+ return apiRequest<{ group: ShippingCostGroup }>(businessPath(`/${groupId}`), {
+ auth: true,
+ signal,
+ })
+}
+
+export async function createShippingCostGroup(payload: ShippingCostGroupFormPayload) {
+ return apiRequest<{ message: string; group: ShippingCostGroup }>(businessPath(), {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
+export async function updateShippingCostGroup(
+ groupId: string,
+ payload: Partial,
+) {
+ return apiRequest<{ message: string; group: ShippingCostGroup }>(
+ businessPath(`/${groupId}`),
+ {
+ method: 'PATCH',
+ auth: true,
+ body: payload,
+ },
+ )
+}
+
+export function getGroupProvinceExceptions(group: ShippingCostGroup) {
+ if (group.provinceExceptions?.length) return group.provinceExceptions
+ return (group.cityRates ?? []).map((rate) => ({
+ id: rate.id,
+ provinceId: rate.cityId,
+ amount: rate.amount,
+ }))
+}
+
+export async function deleteShippingCostGroup(groupId: string) {
+ return apiRequest<{ message: string }>(businessPath(`/${groupId}`), {
+ method: 'DELETE',
+ auth: true,
+ })
+}
diff --git a/apps/business/src/types/shippingCostGroup.ts b/apps/business/src/types/shippingCostGroup.ts
new file mode 100644
index 0000000..5911ee0
--- /dev/null
+++ b/apps/business/src/types/shippingCostGroup.ts
@@ -0,0 +1,67 @@
+export const SHIPPING_METHOD_IDS = [
+ 'post',
+ 'tipax',
+ 'mahex',
+ 'freight',
+ 'air',
+] as const
+
+export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number]
+
+export interface ShippingCostGroupCategory {
+ id: string
+ name: string
+ nameFa: string | null
+ slug: string
+}
+
+export interface ShippingCostGroupCityRate {
+ id: string
+ cityId: string
+ amount: number
+}
+
+export interface ShippingCostGroupProvinceException {
+ id: string
+ provinceId: string
+ amount: number
+}
+
+export interface ShippingCostGroup {
+ id: string
+ businessId: string
+ nameFa: string
+ nameEn: string
+ description: string | null
+ methods: ShippingMethodId[]
+ defaultAmount: number
+ sortOrder: number
+ isActive: boolean
+ isDefault: boolean
+ categoryIds: string[]
+ categories: ShippingCostGroupCategory[]
+ cityRates: ShippingCostGroupCityRate[]
+ provinceExceptions?: ShippingCostGroupProvinceException[]
+ createdAt: string
+ updatedAt: string
+}
+
+export interface ShippingCostGroupFormPayload {
+ nameFa: string
+ nameEn: string
+ description?: string
+ methods: ShippingMethodId[]
+ defaultAmount: number
+ isDefault?: boolean
+ categoryIds?: string[]
+ provinceExceptions?: Array<{ provinceId: string; amount: number }>
+ sortOrder?: number
+ isActive?: boolean
+}
+
+export interface ShippingCostGroupsListResponse {
+ items: ShippingCostGroup[]
+ total: number
+ page: number
+ pageSize: number
+}
diff --git a/apps/customer/src/components/OrderItemsModal.module.css b/apps/customer/src/components/OrderItemsModal.module.css
index 6d288b7..a6c896b 100644
--- a/apps/customer/src/components/OrderItemsModal.module.css
+++ b/apps/customer/src/components/OrderItemsModal.module.css
@@ -140,6 +140,113 @@
color: var(--primary);
}
+.shippingBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ border-radius: var(--radius-sm);
+ background: rgba(148, 163, 184, 0.05);
+}
+
+.shippingSectionTitle {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.addressText {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+.shippingGroupList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.shippingGroupCard {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+
+.shippingGroupHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.shippingGroupTitle {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.shippingGroupAmount {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.shippingMethod {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--primary);
+}
+
+.shippingItemNames {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.totalsBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(148, 163, 184, 0.25);
+}
+
+.totalsRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.totalsValue {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.totalsRowGrand {
+ padding-top: 6px;
+ border-top: 1px solid rgba(148, 163, 184, 0.18);
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
.empty {
font-size: 13px;
color: var(--text-muted);
diff --git a/apps/customer/src/components/OrderItemsModal.tsx b/apps/customer/src/components/OrderItemsModal.tsx
index a06fdda..20245ac 100644
--- a/apps/customer/src/components/OrderItemsModal.tsx
+++ b/apps/customer/src/components/OrderItemsModal.tsx
@@ -31,6 +31,54 @@ function totalQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function parseOrderShipping(shippingAddress: Record) {
+ const root = asRecord(shippingAddress) ?? {}
+ const deliveryMode =
+ root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
+ ? root.deliveryMode
+ : root.province === 'Pickup'
+ ? 'pickup'
+ : 'delivery'
+ const shipping = asRecord(root.shipping)
+ const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
+ const groups = groupsRaw
+ .map((entry) => {
+ const row = asRecord(entry)
+ if (!row) return null
+ const groupId = typeof row.groupId === 'string' ? row.groupId : ''
+ if (!groupId) return null
+ return {
+ groupId,
+ title: typeof row.title === 'string' ? row.title : '',
+ methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
+ amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
+ itemNames: Array.isArray(row.itemNames)
+ ? row.itemNames.filter((name): name is string => typeof name === 'string')
+ : [],
+ }
+ })
+ .filter((group): group is NonNullable => Boolean(group))
+
+ const addressParts = [
+ typeof root.province === 'string' ? root.province : '',
+ typeof root.city === 'string' ? root.city : '',
+ typeof root.address === 'string' ? root.address : '',
+ typeof root.postalCode === 'string' ? root.postalCode : '',
+ ].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
+
+ return {
+ deliveryMode,
+ addressLine: addressParts.join(' · '),
+ groups,
+ }
+}
+
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
@@ -106,6 +154,47 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
+ {(() => {
+ const shippingInfo = parseOrderShipping(order.shippingAddress)
+ return (
+
+
{t('orderItems.delivery')}
+ {shippingInfo.deliveryMode === 'pickup' ? (
+
{t('orderItems.pickup')}
+ ) : shippingInfo.addressLine ? (
+
{shippingInfo.addressLine}
+ ) : null}
+
+ {shippingInfo.groups.length > 0 ? (
+
+ ) : null}
+
+ )
+ })()}
+
{order.items.length === 0 ? (
) : (
@@ -145,15 +234,35 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
)}
-
-
- {t('orderItems.summary', {
- count: itemCount,
- itemsLabel:
- itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
- })}
-
-
{formatIrtPrice(order.total)}
+
+
+ {t('orderItems.subtotal')}
+ {formatIrtPrice(order.subtotal)}
+
+
+ {t('orderItems.shipping')}
+
+ {order.shippingTotal > 0
+ ? formatIrtPrice(order.shippingTotal)
+ : t('orderItems.freeShipping')}
+
+
+ {order.discountTotal > 0 ? (
+
+ {t('orderItems.discount')}
+ {formatIrtPrice(order.discountTotal)}
+
+ ) : null}
+
+
+ {t('orderItems.summary', {
+ count: itemCount,
+ itemsLabel:
+ itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
+ })}
+
+ {formatIrtPrice(order.total)}
+
diff --git a/apps/customer/src/components/checkout/CheckoutAddAddressModal.tsx b/apps/customer/src/components/checkout/CheckoutAddAddressModal.tsx
index 1076ba1..3fbb725 100644
--- a/apps/customer/src/components/checkout/CheckoutAddAddressModal.tsx
+++ b/apps/customer/src/components/checkout/CheckoutAddAddressModal.tsx
@@ -8,7 +8,7 @@ import styles from './CheckoutAddAddressModal.module.css'
interface CheckoutAddAddressModalProps {
open: boolean
onClose: () => void
- onSaved: () => void
+ onSaved: (address?: { id: string }) => void
}
const ANIMATION_MS = 220
@@ -52,8 +52,8 @@ export function CheckoutAddAddressModal({ open, onClose, onSaved }: CheckoutAddA
if (!mounted) return null
- function handleSaved() {
- onSaved()
+ function handleSaved(address?: { id: string }) {
+ onSaved(address)
onClose()
}
diff --git a/apps/customer/src/components/checkout/CheckoutAddAddressPanel.tsx b/apps/customer/src/components/checkout/CheckoutAddAddressPanel.tsx
index f4397dc..7b383c1 100644
--- a/apps/customer/src/components/checkout/CheckoutAddAddressPanel.tsx
+++ b/apps/customer/src/components/checkout/CheckoutAddAddressPanel.tsx
@@ -28,7 +28,7 @@ function FieldLabel({
}
interface CheckoutAddAddressPanelProps {
- onSaved: () => void
+ onSaved: (address?: { id: string }) => void
onCancel: () => void
inModal?: boolean
}
@@ -97,7 +97,7 @@ export function CheckoutAddAddressPanel({
setSaving(true)
try {
- await createAddress({
+ const result = await createAddress({
label: label.trim(),
province: province.nameFa || province.nameEn,
city: city.trim(),
@@ -106,7 +106,7 @@ export function CheckoutAddAddressPanel({
landline: landline.trim() || undefined,
})
showToast('آدرس ذخیره شد.', 'success')
- onSaved()
+ onSaved(result.address)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ممکن نشد.')
} finally {
diff --git a/apps/customer/src/components/checkout/CheckoutLayout.module.css b/apps/customer/src/components/checkout/CheckoutLayout.module.css
index f4a1f60..00e56ca 100644
--- a/apps/customer/src/components/checkout/CheckoutLayout.module.css
+++ b/apps/customer/src/components/checkout/CheckoutLayout.module.css
@@ -102,18 +102,20 @@
}
.container {
- width: 60%;
- max-width: none;
+ width: min(1040px, 96%);
min-width: 320px;
}
-@media (max-width: 900px) {
- .container {
- width: 90%;
- }
+.shell {
+ display: flex;
+ align-items: stretch;
+ gap: 16px;
+ width: 100%;
}
.card {
+ flex: 1;
+ min-width: 0;
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
@@ -123,6 +125,18 @@
padding: 28px 28px 24px;
}
+@media (max-width: 900px) {
+ .container {
+ width: 94%;
+ }
+}
+
+@media (max-width: 760px) {
+ .shell {
+ flex-direction: column;
+ }
+}
+
@media (max-width: 480px) {
.header {
padding: 12px 16px;
diff --git a/apps/customer/src/components/checkout/CheckoutLayout.tsx b/apps/customer/src/components/checkout/CheckoutLayout.tsx
index a821025..0c42d2a 100644
--- a/apps/customer/src/components/checkout/CheckoutLayout.tsx
+++ b/apps/customer/src/components/checkout/CheckoutLayout.tsx
@@ -54,9 +54,7 @@ export function CheckoutLayout() {
diff --git a/apps/customer/src/components/checkout/CheckoutStepper.module.css b/apps/customer/src/components/checkout/CheckoutStepper.module.css
index f91c96d..3373609 100644
--- a/apps/customer/src/components/checkout/CheckoutStepper.module.css
+++ b/apps/customer/src/components/checkout/CheckoutStepper.module.css
@@ -1,3 +1,4 @@
+/* Horizontal top stepper */
.stepper {
display: flex;
align-items: flex-end;
@@ -94,3 +95,91 @@
margin: 0 4px 13px;
}
}
+
+/* Tall side panel — current step only */
+.rail {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 112px;
+ flex-shrink: 0;
+ min-height: 320px;
+ align-self: stretch;
+ padding: 18px 12px;
+ background: var(--glass-bg);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: var(--glass-shadow);
+ font-family: inherit;
+}
+
+.railCurrent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ text-align: center;
+ min-height: 160px;
+}
+
+.railIconWrap {
+ width: 52px;
+ height: 52px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ color: var(--primary);
+ background: rgba(var(--primary-rgb) / 0.14);
+ border: 1px solid rgba(var(--primary-rgb) / 0.28);
+}
+
+.railTitle {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.35;
+}
+
+.railIndex {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+@media (max-width: 760px) {
+ .rail {
+ width: 100%;
+ min-height: 0;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 14px 16px;
+ }
+
+ .railCurrent {
+ flex: 0 0 auto;
+ min-height: 0;
+ flex-direction: row;
+ gap: 10px;
+ }
+
+ .railIconWrap {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ }
+
+ .railTitle {
+ font-size: 14px;
+ }
+
+ .railIndex {
+ display: none;
+ }
+}
diff --git a/apps/customer/src/components/checkout/CheckoutStepper.tsx b/apps/customer/src/components/checkout/CheckoutStepper.tsx
index ac06f8a..34c9984 100644
--- a/apps/customer/src/components/checkout/CheckoutStepper.tsx
+++ b/apps/customer/src/components/checkout/CheckoutStepper.tsx
@@ -1,13 +1,13 @@
-import { Check } from 'lucide-react'
+import { CreditCard, LogIn, ShoppingCart, Truck, type LucideIcon } from 'lucide-react'
import styles from './CheckoutStepper.module.css'
export type CheckoutStepId = 'cart' | 'login' | 'delivery' | 'payment'
-const STEPS: { id: CheckoutStepId; label: string }[] = [
- { id: 'cart', label: 'سبد خرید' },
- { id: 'login', label: 'ورود' },
- { id: 'delivery', label: 'ارسال' },
- { id: 'payment', label: 'پرداخت' },
+const STEPS: { id: CheckoutStepId; label: string; Icon: LucideIcon }[] = [
+ { id: 'cart', label: 'سبد خرید', Icon: ShoppingCart },
+ { id: 'login', label: 'ورود', Icon: LogIn },
+ { id: 'delivery', label: 'ارسال', Icon: Truck },
+ { id: 'payment', label: 'پرداخت', Icon: CreditCard },
]
interface CheckoutStepperProps {
@@ -42,7 +42,7 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
>
{step.label}
- {isDone ? : index + 1}
+ {isDone ? '✓' : index + 1}
@@ -60,3 +60,35 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
)
}
+
+interface CheckoutStepSidePanelProps {
+ current: CheckoutStepId
+ isAuthenticated: boolean
+}
+
+export function CheckoutStepSidePanel({
+ current,
+ isAuthenticated,
+}: CheckoutStepSidePanelProps) {
+ const visibleSteps = isAuthenticated
+ ? STEPS.filter((step) => step.id !== 'login')
+ : STEPS
+
+ const currentStep = visibleSteps.find((step) => step.id === current) ?? visibleSteps[0]
+ const currentIdx = visibleSteps.findIndex((step) => step.id === current)
+ const CurrentIcon = currentStep.Icon
+
+ return (
+