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:
co-authored by
Cursor
parent
95390933a7
commit
c59ca4444f
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user