Add shipping cost management and checkout shipping fees.

Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-02 21:52:47 +03:30
co-authored by Cursor
parent 95390933a7
commit c59ca4444f
38 changed files with 5353 additions and 98 deletions
+2
View File
@@ -37,6 +37,7 @@ import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
import { StoreSettingsPage } from './pages/StoreSettingsPage'
import { StoreShippingPage } from './pages/StoreShippingPage'
import { StoreInstallmentsPage } from './pages/StoreInstallmentsPage'
import { AddStoreInstallmentPage } from './pages/AddStoreInstallmentPage'
import { BlogPage } from './pages/BlogPage'
@@ -111,6 +112,7 @@ function App() {
<Route path="store" element={<StorePage />} />
<Route path="store/items" element={<StoreItemsPage />} />
<Route path="store/orders" element={<OrdersPage />} />
<Route path="store/shipping" element={<StoreShippingPage />} />
<Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/installments" element={<StoreInstallmentsPage />} />
<Route path="store/installments/new" element={<AddStoreInstallmentPage />} />
@@ -0,0 +1,134 @@
.wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.wrapperOpen {
z-index: 20;
}
.treePanel {
position: static;
max-height: min(280px, 40vh);
overflow-y: auto;
margin-top: 0;
box-shadow: none;
}
.treeRow {
padding: 3px 6px;
min-height: 0;
}
.selectBtn {
align-items: center;
gap: 8px;
padding: 1px 0;
}
.checkbox {
width: 15px;
height: 15px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.55);
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: var(--primary);
background: var(--card-media-bg);
}
.checkboxChecked {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.namePair {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
font-size: 13px;
line-height: 1.25;
}
.nameEn {
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.nameFa {
font-weight: 600;
color: var(--text-primary);
}
.nameSep {
color: var(--text-muted);
font-weight: 500;
}
.selectedList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(160px, 22vh);
overflow-y: auto;
}
.selectedRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(148, 163, 184, 0.06);
direction: rtl;
}
.selectedRow .namePair {
flex: 0 1 auto;
min-width: 0;
justify-content: flex-end;
}
.selectedRow .nameEn,
.selectedRow .nameFa {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeBtn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.removeBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.18);
color: var(--text-primary);
}
.removeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,341 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import type { CategoryTreeItem } from './CategoryTreeSelect'
import treeStyles from './CategoryTree.module.css'
import selectStyles from './CategoryTreeSelect.module.css'
import styles from './CategoryTreeMultiSelect.module.css'
interface CategoryTreeMultiSelectProps {
categories: CategoryTreeItem[]
selectedIds: string[]
onToggle: (id: string) => void
placeholder?: string
disabled?: boolean
id?: string
}
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
return categories
.filter((category) => category.parentId === parentId)
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
}
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
const byId = new Map(categories.map((category) => [category.id, category]))
const ids: string[] = []
let current = byId.get(id)
while (current?.parentId) {
ids.push(current.parentId)
current = byId.get(current.parentId)
}
return ids
}
function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase()
if (!q) return true
return (
category.nameEn.toLowerCase().includes(q) ||
category.nameFa.toLowerCase().includes(q) ||
category.nameFa.includes(query.trim())
)
}
function visibleIdsForQuery(
categories: CategoryTreeItem[],
query: string,
): Set<string> | null {
const q = query.trim()
if (!q) return null
const matched = categories.filter((category) => categoryMatches(category, q))
const visible = new Set<string>()
for (const category of matched) {
visible.add(category.id)
for (const ancestorId of getAncestorIds(categories, category.id)) {
visible.add(ancestorId)
}
}
return visible
}
function CategoryNamePair({ category }: { category: CategoryTreeItem }) {
const nameEn = category.nameEn?.trim() || ''
const nameFa = category.nameFa?.trim() || ''
return (
<span className={styles.namePair} dir="ltr">
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{nameEn && nameFa ? <span className={styles.nameSep}>·</span> : null}
{nameFa ? (
<span className={styles.nameFa} dir="rtl" lang="fa">
{nameFa}
</span>
) : null}
</span>
)
}
function TreeNodes({
categories,
parentId,
selectedIds,
expandedIds,
visibleIds,
forceExpanded,
disabled,
onToggleExpand,
onToggleSelect,
}: {
categories: CategoryTreeItem[]
parentId: string | null
selectedIds: Set<string>
expandedIds: Set<string>
visibleIds: Set<string> | null
forceExpanded: boolean
disabled: boolean
onToggleExpand: (id: string) => void
onToggleSelect: (id: string) => void
}) {
const t = useT()
const nodes = getChildren(categories, parentId).filter(
(category) => !visibleIds || visibleIds.has(category.id),
)
return (
<>
{nodes.map((category) => {
const childCount = getChildren(categories, category.id).some(
(child) => !visibleIds || visibleIds.has(child.id),
)
const expanded = forceExpanded || expandedIds.has(category.id)
const selected = selectedIds.has(category.id)
return (
<div key={category.id}>
<div
className={`${selectStyles.row} ${styles.treeRow} ${selected ? selectStyles.rowSelected : ''}`}
>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={selectStyles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggleExpand(category.id)}
disabled={disabled}
>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
<span className={selectStyles.toggleSpacer} />
)}
<button
type="button"
className={`${selectStyles.selectBtn} ${styles.selectBtn}`}
onClick={() => onToggleSelect(category.id)}
disabled={disabled}
aria-pressed={selected}
>
<span
className={`${styles.checkbox} ${selected ? styles.checkboxChecked : ''}`}
aria-hidden="true"
>
{selected ? '✓' : ''}
</span>
<CategoryNamePair category={category} />
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
selectedIds={selectedIds}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
disabled={disabled}
onToggleExpand={onToggleExpand}
onToggleSelect={onToggleSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
export function CategoryTreeMultiSelect({
categories,
selectedIds,
onToggle,
placeholder,
disabled = false,
id,
}: CategoryTreeMultiSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const containerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const searchPlaceholder = placeholder ?? t('categories.select.search')
const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query),
[categories, query],
)
const selectedCategories = useMemo(
() =>
selectedIds
.map((id) => categories.find((category) => category.id === id))
.filter((category): category is CategoryTreeItem => Boolean(category)),
[categories, selectedIds],
)
useEffect(() => {
setExpandedIds((prev) => {
const next = new Set(prev)
for (const id of selectedIds) {
for (const ancestorId of getAncestorIds(categories, id)) {
next.add(ancestorId)
}
}
return next
})
}, [categories, selectedIds])
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
function handleToggleExpand(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
const searching = query.trim().length > 0
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
return (
<div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
<Search size={16} className={selectStyles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={selectStyles.input}
disabled={disabled}
placeholder={searchPlaceholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
onClick={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{query ? (
<button
type="button"
className={selectStyles.clearBtn}
onClick={() => setQuery('')}
aria-label={t('categories.select.clear')}
disabled={disabled}
>
<X size={14} />
</button>
) : null}
</div>
{open && !disabled ? (
<div className={`${selectStyles.panel} ${styles.treePanel}`}>
{!hasVisible ? (
<p className={selectStyles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
selectedIds={selectedSet}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
disabled={disabled}
onToggleExpand={handleToggleExpand}
onToggleSelect={onToggle}
/>
)}
</div>
) : null}
{selectedCategories.length > 0 ? (
<div className={styles.selectedList}>
{selectedCategories.map((category) => (
<div key={category.id} className={styles.selectedRow}>
<CategoryNamePair category={category} />
<button
type="button"
className={styles.removeBtn}
onClick={() => onToggle(category.id)}
disabled={disabled}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
</div>
))}
</div>
) : null}
</div>
)
}
@@ -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);
@@ -30,6 +30,54 @@ function totalQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
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<typeof group> => 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)
@@ -105,6 +153,47 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</span>
</div>
{(() => {
const shippingInfo = parseOrderShipping(order.shippingAddress)
return (
<div className={styles.shippingBlock}>
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
{shippingInfo.deliveryMode === 'pickup' ? (
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
) : shippingInfo.addressLine ? (
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
) : null}
{shippingInfo.groups.length > 0 ? (
<ul className={styles.shippingGroupList}>
{shippingInfo.groups.map((group) => (
<li key={group.groupId} className={styles.shippingGroupCard}>
<div className={styles.shippingGroupHeader}>
<span className={styles.shippingGroupTitle}>{group.title}</span>
<span className={styles.shippingGroupAmount}>
{group.amount > 0
? formatIrtPrice(group.amount)
: t('orderItems.freeShipping')}
</span>
</div>
{group.methodLabel ? (
<div className={styles.shippingMethod}>{group.methodLabel}</div>
) : null}
{group.itemNames.length > 0 ? (
<ul className={styles.shippingItemNames}>
{group.itemNames.map((name) => (
<li key={`${group.groupId}-${name}`}>{name}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : null}
</div>
)
})()}
{order.items.length === 0 ? (
<p className={styles.empty}>{t('orderItems.empty')}</p>
) : (
@@ -144,15 +233,35 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div>
)}
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
<div className={styles.totalsBlock}>
<div className={styles.totalsRow}>
<span>{t('orderItems.subtotal')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
</div>
<div className={styles.totalsRow}>
<span>{t('orderItems.shipping')}</span>
<span className={styles.totalsValue}>
{order.shippingTotal > 0
? formatIrtPrice(order.shippingTotal)
: t('orderItems.freeShipping')}
</span>
</div>
{order.discountTotal > 0 ? (
<div className={styles.totalsRow}>
<span>{t('orderItems.discount')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
</div>
) : null}
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
<span>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div>
</div>
<div className={modalStyles.actions}>
@@ -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);
}
}
@@ -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<ShippingMethodId, string> = {
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<ShippingMethodId[]>([])
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<HTMLFormElement>) {
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(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="shipping-group-modal-title" className={styles.title}>
{editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="shipping-name-fa">{t('store.shipping.form.nameFa')}</label>
<input
id="shipping-name-fa"
type="text"
dir="rtl"
className="faText"
placeholder={t('store.shipping.form.nameFaPlaceholder')}
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-name-en">{t('store.shipping.form.nameEn')}</label>
<input
id="shipping-name-en"
type="text"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-description">{t('store.shipping.form.description')}</label>
<textarea
id="shipping-description"
rows={3}
dir={isFa ? 'rtl' : 'ltr'}
placeholder={t('store.shipping.form.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('store.shipping.form.methods')}</span>
<p className={styles.hint}>{t('store.shipping.form.methodsHint')}</p>
<div className={styles.chipGrid} role="group" aria-label={t('store.shipping.form.methods')}>
{SHIPPING_METHOD_IDS.map((method) => {
const checked = methods.includes(method)
return (
<button
key={method}
type="button"
className={`${styles.chip} ${checked ? styles.chipSelected : ''}`}
onClick={() => toggleMethod(method)}
disabled={isSubmitting}
aria-pressed={checked}
>
<span className={styles.chipFa}>
{t(`store.shipping.method.${method}` as `store.shipping.method.${typeof method}`)}
</span>
<span className={styles.chipEn} dir="ltr" lang="en">
{METHOD_EN_LABELS[method]}
</span>
</button>
)
})}
</div>
</div>
<div className={styles.field}>
<label htmlFor="shipping-default-amount">
{t('store.shipping.form.defaultAmount')}
</label>
<p className={styles.hint}>{t('store.shipping.form.defaultAmountHint')}</p>
<div className={styles.amountRow}>
<input
id="shipping-default-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.defaultAmountPlaceholder')}
value={defaultAmount}
onChange={(e) => setDefaultAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting}
required
/>
<span className={styles.amountUnit}>IRT</span>
</div>
</div>
<label className={styles.checkRow} htmlFor="shipping-is-default">
<input
id="shipping-is-default"
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
disabled={isSubmitting}
/>
<span className={styles.checkText}>
<span className={styles.checkTitle}>
{t('store.shipping.form.isDefault')}
</span>
<span className={styles.checkHint}>
{t('store.shipping.form.isDefaultHint')}
</span>
</span>
</label>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editingGroup
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -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;
}
}
@@ -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 (
<div className={`${styles.row} ${disabled ? styles.rowDisabled : ''}`}>
<div className={styles.titleBlock}>
<div className={styles.names}>
{nameFa ? <span className={styles.nameFa}>{nameFa}</span> : null}
{nameFa && nameEn ? <span className={styles.nameSep}>·</span> : null}
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{group.isDefault ? (
<span className={styles.defaultBadge}>
{t('store.shipping.row.defaultBadge')}
</span>
) : null}
</div>
{group.description ? (
<p className={styles.description}>{group.description}</p>
) : null}
</div>
<div className={styles.metrics}>
<div className={styles.chip}>
<span className={styles.chipLabel}>{t('store.shipping.row.defaultAmount')}</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>{formatIrtPrice(group.defaultAmount)}</span>
</span>
</div>
{group.methods.map((method) => (
<div key={method} className={styles.methodChip}>
{t(methodLabelKey(method))}
</div>
))}
</div>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.categories')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onCategories(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.categories')}
>
<Layers size={16} />
{categoryCount > 0 ? (
<span className={styles.categoryBadge}>{categoryCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.exceptions')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.exceptionBtn}`}
onClick={() => onExceptions(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.exceptions')}
>
<MapPin size={16} />
{exceptionCount > 0 ? (
<span className={styles.exceptionBadge}>{exceptionCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onEdit(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -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> | 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<CategoryTreeItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
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(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !busy && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-categories-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className={modalStyles.header}>
<div>
<h2 id="shipping-group-categories-title" className={modalStyles.title}>
{t('store.shipping.groupCategories.title')}
</h2>
<p className={modalStyles.subtitle}>
{t('store.shipping.groupCategories.subtitle', { name: groupName })}
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error ? <p className={styles.error}>{error}</p> : null}
{isLoading && !treeReady ? (
<p className={styles.empty}>{t('store.shipping.groupCategories.loading')}</p>
) : (
<CategoryTreeMultiSelect
categories={categories}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder={t('categories.select.search')}
disabled={busy}
/>
)}
<div className={styles.footer}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={busy}
>
{t('store.shipping.form.cancel')}
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={() => void handleSave()}
disabled={busy}
>
{busy
? t('store.shipping.form.saving')
: t('store.shipping.groupCategories.save')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -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); }
}
@@ -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> | 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<ShippingCostGroupProvinceException[]>([])
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<ShippingCostGroupProvinceException | null>(null)
const [deleteTarget, setDeleteTarget] =
useState<ShippingCostGroupProvinceException | null>(null)
const [saving, setSaving] = useState(false)
const provinceNameById = useMemo(() => {
const map = new Map<string, string>()
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(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !busy && !addOpen && !deleteTarget && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="group-exceptions-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div className={styles.headerText}>
<h3 id="group-exceptions-modal-title" className={styles.title}>
{t('store.shipping.groupException.title')}
</h3>
<p className={styles.subtitle}>
{t('store.shipping.groupException.subtitle', { name: groupName })}
</p>
</div>
<div className={styles.headerActions}>
<Tooltip label={t('store.shipping.groupException.add')}>
<button
type="button"
className={styles.addBtn}
onClick={() => {
setEditing(null)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.groupException.add')}
>
<Plus size={18} />
</button>
</Tooltip>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
</div>
<div className={styles.body}>
{items.length === 0 ? (
<p className={styles.empty}>{t('store.shipping.groupException.empty')}</p>
) : (
<ul className={styles.list}>
{items.map((item) => (
<li key={item.provinceId} className={styles.row}>
<span className={styles.province}>
{provinceNameById.get(item.provinceId) ?? item.provinceId}
</span>
<span className={styles.amount}>{formatIrtPrice(item.amount)}</span>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => {
setEditing(item)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => setDeleteTarget(item)}
disabled={busy}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<StaticShippingExceptionModal
open={addOpen}
provinces={provinces}
usedProvinceIds={items.map((item) => 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)}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('store.shipping.groupException.deleteTitle')}
message={
deleteTarget
? t('store.shipping.groupException.deleteMessage', {
name:
provinceNameById.get(deleteTarget.provinceId) ??
deleteTarget.provinceId,
})
: ''
}
onConfirm={() => void handleDelete()}
onCancel={() => !busy && setDeleteTarget(null)}
/>
</div>,
document.body,
)
}
@@ -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); }
}
@@ -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<HTMLFormElement>) {
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(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="static-exception-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="static-exception-modal-title" className={styles.title}>
{editing
? t('store.shipping.static.exception.editTitle')
: t('store.shipping.static.exception.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="static-exception-province">
{t('store.shipping.static.exception.province')}
</label>
<select
id="static-exception-province"
value={provinceId}
onChange={(e) => setProvinceId(e.target.value)}
disabled={isSubmitting || Boolean(editing)}
required
>
<option value="">{t('store.shipping.static.exception.provincePlaceholder')}</option>
{availableProvinces.map((province) => (
<option key={province.id} value={province.id}>
{isFa ? province.nameFa || province.nameEn : province.nameEn || province.nameFa}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="static-exception-amount">
{t('store.shipping.static.exception.amount')}
</label>
<div className={styles.amountRow}>
<input
id="static-exception-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.static.amountPlaceholder')}
value={amount}
onChange={(e) => setAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting}
required
/>
<span className={styles.amountUnit}>IRT</span>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editing
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
+213
View File
@@ -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<MessageKey, string> = {
'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<MessageKey, string> = {
'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<MessageKey, string> = {
'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',
@@ -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;
}
}
@@ -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<StaticShippingProvinceException[]>([])
const [provinces, setProvinces] = useState<CityOption[]>([])
const [groups, setGroups] = useState<ShippingCostGroup[]>([])
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<ShippingCostGroup | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ShippingCostGroup | null>(null)
const [exceptionModalOpen, setExceptionModalOpen] = useState(false)
const [editingException, setEditingException] =
useState<StaticShippingProvinceException | null>(null)
const [deleteExceptionTarget, setDeleteExceptionTarget] =
useState<StaticShippingProvinceException | null>(null)
const [groupExceptionsTarget, setGroupExceptionsTarget] =
useState<ShippingCostGroup | null>(null)
const [groupCategoriesTarget, setGroupCategoriesTarget] =
useState<ShippingCostGroup | null>(null)
const groupsEnabled = !staticEnabled
const groupsDisabled = !groupsEnabled
const exceptionsDisabled = !staticEnabled
const staticInactive = !staticEnabled
const groupsInactive = !groupsEnabled
const provinceNameById = useMemo(() => {
const map = new Map<string, string>()
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<StoreSettings> = {
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 (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: t('title.shipping') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="SHIPPING FEES">{t('title.shipping')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('store.shipping.page.subtitle')}</p>
</div>
</div>
{error ? (
<div className={listStyles.error} role="alert">
{error}
</div>
) : null}
<section className={`${panelStyles.panel} ${styles.methodPanel}`}>
{isLoading ? (
<p className={panelStyles.status}>{t('store.shipping.loading')}</p>
) : (
<>
<div className={styles.methodTopRow}>
<Switch
id="static-shipping"
checked={staticEnabled}
disabled={savingStatic}
aria-label={t('store.shipping.static.title')}
onChange={(checked) => void handleStaticToggle(checked)}
/>
<div
className={`${styles.methodBody} ${staticInactive ? styles.methodInactive : ''}`}
>
<div className={styles.methodHeading}>
<label htmlFor="static-shipping" className={styles.methodTitle}>
{t('store.shipping.static.title')}
</label>
<p className={styles.methodDesc}>{t('store.shipping.static.desc')}</p>
</div>
<div className={styles.amountInline}>
<label htmlFor="static-shipping-amount" className={styles.srOnly}>
{t('store.shipping.static.amount')}
</label>
<input
id="static-shipping-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
className={styles.amountInput}
placeholder={t('store.shipping.static.amountPlaceholder')}
value={staticAmount}
onChange={(e) => setStaticAmount(formatIrtInput(e.target.value))}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.static.amount')}
/>
<span className={styles.amountUnit}>IRT</span>
</div>
<Tooltip label={t('store.shipping.form.save')}>
<button
type="button"
className={styles.amountSaveBtn}
onClick={() => void handleStaticAmountSave()}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.form.save')}
>
<Check size={18} />
</button>
</Tooltip>
</div>
</div>
<div
className={`${styles.exceptionsBlock} ${staticInactive || exceptionsDisabled ? styles.methodInactive : ''}`}
>
<div className={styles.exceptionsHeader}>
<div className={styles.exceptionsHeading}>
<h4 className={styles.exceptionsTitle}>
{t('store.shipping.static.exception.title')}
</h4>
<p className={styles.exceptionsDesc}>
{staticInactive
? t('store.shipping.static.exception.disabledHint')
: t('store.shipping.static.exception.desc')}
</p>
</div>
<Tooltip label={t('store.shipping.static.exception.add')}>
<button
type="button"
className={styles.exceptionAddBtn}
onClick={() => {
if (staticInactive) return
setEditingException(null)
setExceptionModalOpen(true)
}}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.static.exception.add')}
>
<Plus size={18} />
</button>
</Tooltip>
</div>
{exceptions.length === 0 ? (
<p className={styles.exceptionsEmpty}>
{t('store.shipping.static.exception.empty')}
</p>
) : (
<ul className={styles.exceptionsList}>
{exceptions.map((item) => (
<li key={item.provinceId} className={styles.exceptionRow}>
<span className={styles.exceptionProvince}>
{provinceNameById.get(item.provinceId) ?? item.provinceId}
</span>
<span className={styles.exceptionAmount}>
{formatIrtPrice(item.amount)}
</span>
<div className={styles.exceptionControls}>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.exceptionControlBtn}
onClick={() => {
setEditingException(item)
setExceptionModalOpen(true)
}}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.exceptionControlBtn} ${styles.exceptionDanger}`}
onClick={() => setDeleteExceptionTarget(item)}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</section>
<div className={styles.sectionDivider} aria-hidden="true" />
<section className={`${panelStyles.panel} ${styles.methodPanel}`}>
{isLoading ? (
<p className={panelStyles.status}>{t('store.shipping.loading')}</p>
) : (
<>
<div className={styles.methodTopRow}>
<Switch
id="groups-shipping"
checked={groupsEnabled}
disabled={savingStatic}
aria-label={t('store.shipping.groups.title')}
onChange={(checked) => void handleGroupsToggle(checked)}
/>
<div
className={`${styles.methodBody} ${groupsInactive ? styles.methodInactive : ''}`}
>
<div className={styles.methodHeading}>
<label htmlFor="groups-shipping" className={styles.methodTitle}>
{t('store.shipping.groups.title')}
</label>
<p className={styles.methodDesc}>
{groupsInactive
? t('store.shipping.groups.disabledHint')
: t('store.shipping.groups.desc')}
</p>
</div>
<Tooltip label={t('store.shipping.add')}>
<button
type="button"
className={styles.exceptionAddBtn}
onClick={openCreateModal}
disabled={groupsInactive || isSubmitting}
aria-label={t('store.shipping.add')}
>
<Plus size={18} />
</button>
</Tooltip>
</div>
</div>
<div
className={`${styles.groupsContent} ${groupsInactive ? styles.methodInactive : ''}`}
>
{groups.length === 0 ? (
<EmptyState
icon={Truck}
message={t('store.shipping.empty')}
actionLabel={groupsInactive ? undefined : t('store.shipping.add')}
onAction={groupsInactive ? undefined : openCreateModal}
/>
) : (
<div className={styles.groupsList}>
{groups.map((group) => (
<ShippingCostGroupRow
key={group.id}
group={group}
disabled={groupsInactive}
onEdit={openEditModal}
onCategories={(id) => {
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)
}}
/>
))}
</div>
)}
</div>
</>
)}
</section>
<ShippingCostGroupModal
open={modalOpen}
editingGroup={editingGroup}
isSubmitting={isSubmitting}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingGroup(null)
}
}}
onSubmit={(payload) => void handleModalSubmit(payload)}
/>
<ShippingGroupExceptionsModal
open={!!groupExceptionsTarget}
groupName={
groupExceptionsTarget
? groupExceptionsTarget.nameFa || groupExceptionsTarget.nameEn
: ''
}
provinces={provinces}
exceptions={
groupExceptionsTarget
? getGroupProvinceExceptions(groupExceptionsTarget)
: []
}
isSubmitting={isSubmitting}
onClose={() => !isSubmitting && setGroupExceptionsTarget(null)}
onSave={handleGroupExceptionsSave}
/>
<ShippingGroupCategoriesModal
open={!!groupCategoriesTarget}
groupName={
groupCategoriesTarget
? groupCategoriesTarget.nameFa || groupCategoriesTarget.nameEn
: ''
}
selectedCategoryIds={groupCategoriesTarget?.categoryIds ?? []}
isSubmitting={isSubmitting}
onClose={() => !isSubmitting && setGroupCategoriesTarget(null)}
onSave={handleGroupCategoriesSave}
/>
<StaticShippingExceptionModal
open={exceptionModalOpen}
provinces={provinces}
usedProvinceIds={exceptions.map((item) => item.provinceId)}
editing={editingException}
isSubmitting={isSubmitting || savingStatic}
onClose={() => {
if (!isSubmitting && !savingStatic) {
setExceptionModalOpen(false)
setEditingException(null)
}
}}
onSubmit={(data) => void handleExceptionSubmit(data)}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('store.shipping.deleteTitle')}
message={
deleteTarget
? t('store.shipping.deleteMessage', {
name: deleteTarget.nameFa || deleteTarget.nameEn,
})
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<ConfirmDeleteModal
open={!!deleteExceptionTarget}
title={t('store.shipping.static.exception.deleteTitle')}
message={
deleteExceptionTarget
? t('store.shipping.static.exception.deleteMessage', {
name:
provinceNameById.get(deleteExceptionTarget.provinceId) ??
deleteExceptionTarget.provinceId,
})
: ''
}
onConfirm={confirmDeleteException}
onCancel={() => !isSubmitting && setDeleteExceptionTarget(null)}
/>
</main>
)
}
@@ -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 = {
@@ -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<ShippingCostGroupsListResponse>(
`${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<ShippingCostGroupFormPayload>,
) {
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,
})
}
@@ -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
}