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
@@ -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>
)
}