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
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -31,6 +31,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)
|
||||
@@ -106,6 +154,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 ? (
|
||||
<EmptyState layout="inline" message={t('orderItems.empty')} />
|
||||
) : (
|
||||
@@ -145,15 +234,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}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './CheckoutAddAddressModal.module.css'
|
||||
interface CheckoutAddAddressModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onSaved: (address?: { id: string }) => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
@@ -52,8 +52,8 @@ export function CheckoutAddAddressModal({ open, onClose, onSaved }: CheckoutAddA
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
function handleSaved() {
|
||||
onSaved()
|
||||
function handleSaved(address?: { id: string }) {
|
||||
onSaved(address)
|
||||
onClose()
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function FieldLabel({
|
||||
}
|
||||
|
||||
interface CheckoutAddAddressPanelProps {
|
||||
onSaved: () => void
|
||||
onSaved: (address?: { id: string }) => void
|
||||
onCancel: () => void
|
||||
inModal?: boolean
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function CheckoutAddAddressPanel({
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await createAddress({
|
||||
const result = await createAddress({
|
||||
label: label.trim(),
|
||||
province: province.nameFa || province.nameEn,
|
||||
city: city.trim(),
|
||||
@@ -106,7 +106,7 @@ export function CheckoutAddAddressPanel({
|
||||
landline: landline.trim() || undefined,
|
||||
})
|
||||
showToast('آدرس ذخیره شد.', 'success')
|
||||
onSaved()
|
||||
onSaved(result.address)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ممکن نشد.')
|
||||
} finally {
|
||||
|
||||
@@ -102,18 +102,20 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 60%;
|
||||
max-width: none;
|
||||
width: min(1040px, 96%);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
.shell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
@@ -123,6 +125,18 @@
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: 94%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
|
||||
@@ -54,9 +54,7 @@ export function CheckoutLayout() {
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Horizontal top stepper */
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -94,3 +95,91 @@
|
||||
margin: 0 4px 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tall side panel — current step only */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 112px;
|
||||
flex-shrink: 0;
|
||||
min-height: 320px;
|
||||
align-self: stretch;
|
||||
padding: 18px 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.railCurrent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.railIconWrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.28);
|
||||
}
|
||||
|
||||
.railTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.railIndex {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.rail {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.railCurrent {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.railIconWrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.railTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.railIndex {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { CreditCard, LogIn, ShoppingCart, Truck, type LucideIcon } from 'lucide-react'
|
||||
import styles from './CheckoutStepper.module.css'
|
||||
|
||||
export type CheckoutStepId = 'cart' | 'login' | 'delivery' | 'payment'
|
||||
|
||||
const STEPS: { id: CheckoutStepId; label: string }[] = [
|
||||
{ id: 'cart', label: 'سبد خرید' },
|
||||
{ id: 'login', label: 'ورود' },
|
||||
{ id: 'delivery', label: 'ارسال' },
|
||||
{ id: 'payment', label: 'پرداخت' },
|
||||
const STEPS: { id: CheckoutStepId; label: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'cart', label: 'سبد خرید', Icon: ShoppingCart },
|
||||
{ id: 'login', label: 'ورود', Icon: LogIn },
|
||||
{ id: 'delivery', label: 'ارسال', Icon: Truck },
|
||||
{ id: 'payment', label: 'پرداخت', Icon: CreditCard },
|
||||
]
|
||||
|
||||
interface CheckoutStepperProps {
|
||||
@@ -42,7 +42,7 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
|
||||
>
|
||||
<span className={styles.stepLabel}>{step.label}</span>
|
||||
<span className={styles.stepDot} aria-hidden>
|
||||
{isDone ? <Check size={14} /> : index + 1}
|
||||
{isDone ? '✓' : index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -60,3 +60,35 @@ export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperPro
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckoutStepSidePanelProps {
|
||||
current: CheckoutStepId
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function CheckoutStepSidePanel({
|
||||
current,
|
||||
isAuthenticated,
|
||||
}: CheckoutStepSidePanelProps) {
|
||||
const visibleSteps = isAuthenticated
|
||||
? STEPS.filter((step) => step.id !== 'login')
|
||||
: STEPS
|
||||
|
||||
const currentStep = visibleSteps.find((step) => step.id === current) ?? visibleSteps[0]
|
||||
const currentIdx = visibleSteps.findIndex((step) => step.id === current)
|
||||
const CurrentIcon = currentStep.Icon
|
||||
|
||||
return (
|
||||
<aside className={styles.rail} aria-label={`مرحله جاری: ${currentStep.label}`}>
|
||||
<div className={styles.railCurrent}>
|
||||
<span className={styles.railIconWrap} aria-hidden>
|
||||
<CurrentIcon size={28} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className={styles.railTitle}>{currentStep.label}</span>
|
||||
<span className={styles.railIndex}>
|
||||
گام {currentIdx + 1} از {visibleSteps.length}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,28 @@ import {
|
||||
} from 'react'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
SHIPPING_METHOD_LABELS_FA,
|
||||
type ShippingMethodId,
|
||||
} from '../services/shippingCostService'
|
||||
|
||||
export type DeliveryMode = 'delivery' | 'pickup'
|
||||
|
||||
export interface CheckoutShippingQuote {
|
||||
key: string
|
||||
title: string
|
||||
methodId: string | null
|
||||
methodLabel: string | null
|
||||
amount: number
|
||||
itemNames: string[]
|
||||
}
|
||||
|
||||
interface CheckoutState {
|
||||
deliveryMode: DeliveryMode
|
||||
selectedAddressId: string | null
|
||||
/** Selected courier method per shipping cost group id (or "static"). */
|
||||
shippingMethodByGroupId: Record<string, string>
|
||||
shippingQuotes: CheckoutShippingQuote[]
|
||||
discountCode: string
|
||||
placedOrder: Order | null
|
||||
}
|
||||
@@ -21,14 +37,20 @@ interface CheckoutState {
|
||||
interface CheckoutContextValue extends CheckoutState {
|
||||
setDeliveryMode: (mode: DeliveryMode) => void
|
||||
setSelectedAddressId: (id: string | null) => void
|
||||
setShippingMethodForGroup: (groupId: string, methodId: string) => void
|
||||
setShippingMethodByGroupId: (value: Record<string, string>) => void
|
||||
setShippingQuotes: (quotes: CheckoutShippingQuote[]) => void
|
||||
setDiscountCode: (code: string) => void
|
||||
setPlacedOrder: (order: Order | null) => void
|
||||
resetCheckout: () => void
|
||||
shippingTotal: number
|
||||
}
|
||||
|
||||
const defaultState: CheckoutState = {
|
||||
deliveryMode: 'delivery',
|
||||
selectedAddressId: null,
|
||||
shippingMethodByGroupId: {},
|
||||
shippingQuotes: [],
|
||||
discountCode: '',
|
||||
placedOrder: null,
|
||||
}
|
||||
@@ -37,19 +59,63 @@ function storageKey() {
|
||||
return `meshkee_checkout:${getTenantDomain()}`
|
||||
}
|
||||
|
||||
function readPersisted(): Pick<CheckoutState, 'deliveryMode' | 'selectedAddressId' | 'discountCode'> {
|
||||
function parseShippingQuotes(value: unknown): CheckoutShippingQuote[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const quotes: CheckoutShippingQuote[] = []
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const row = entry as Record<string, unknown>
|
||||
if (typeof row.key !== 'string' || !row.key) continue
|
||||
quotes.push({
|
||||
key: row.key,
|
||||
title: typeof row.title === 'string' ? row.title : '',
|
||||
methodId: typeof row.methodId === 'string' ? row.methodId : null,
|
||||
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')
|
||||
: [],
|
||||
})
|
||||
}
|
||||
return quotes
|
||||
}
|
||||
|
||||
function readPersisted(): Pick<
|
||||
CheckoutState,
|
||||
| 'deliveryMode'
|
||||
| 'selectedAddressId'
|
||||
| 'discountCode'
|
||||
| 'shippingMethodByGroupId'
|
||||
| 'shippingQuotes'
|
||||
> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey())
|
||||
if (!raw) return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
if (!raw) {
|
||||
return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
shippingMethodByGroupId: defaultState.shippingMethodByGroupId,
|
||||
shippingQuotes: defaultState.shippingQuotes,
|
||||
}
|
||||
}
|
||||
const parsed = JSON.parse(raw) as {
|
||||
deliveryMode?: unknown
|
||||
selectedAddressId?: unknown
|
||||
discountCode?: unknown
|
||||
shippingMethodByGroupId?: unknown
|
||||
shippingQuotes?: unknown
|
||||
}
|
||||
const methods =
|
||||
parsed.shippingMethodByGroupId &&
|
||||
typeof parsed.shippingMethodByGroupId === 'object' &&
|
||||
!Array.isArray(parsed.shippingMethodByGroupId)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsed.shippingMethodByGroupId as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: {}
|
||||
return {
|
||||
deliveryMode:
|
||||
parsed.deliveryMode === 'pickup' || parsed.deliveryMode === 'delivery'
|
||||
@@ -59,14 +125,17 @@ function readPersisted(): Pick<CheckoutState, 'deliveryMode' | 'selectedAddressI
|
||||
typeof parsed.selectedAddressId === 'string' && parsed.selectedAddressId
|
||||
? parsed.selectedAddressId
|
||||
: null,
|
||||
discountCode:
|
||||
typeof parsed.discountCode === 'string' ? parsed.discountCode : '',
|
||||
discountCode: typeof parsed.discountCode === 'string' ? parsed.discountCode : '',
|
||||
shippingMethodByGroupId: methods,
|
||||
shippingQuotes: parseShippingQuotes(parsed.shippingQuotes),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
deliveryMode: defaultState.deliveryMode,
|
||||
selectedAddressId: defaultState.selectedAddressId,
|
||||
discountCode: defaultState.discountCode,
|
||||
shippingMethodByGroupId: defaultState.shippingMethodByGroupId,
|
||||
shippingQuotes: defaultState.shippingQuotes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +148,8 @@ function persistPartial(state: CheckoutState) {
|
||||
deliveryMode: state.deliveryMode,
|
||||
selectedAddressId: state.selectedAddressId,
|
||||
discountCode: state.discountCode,
|
||||
shippingMethodByGroupId: state.shippingMethodByGroupId,
|
||||
shippingQuotes: state.shippingQuotes,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
@@ -96,7 +167,11 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, deliveryMode }
|
||||
const next = {
|
||||
...prev,
|
||||
deliveryMode,
|
||||
shippingQuotes: deliveryMode === 'pickup' ? [] : prev.shippingQuotes,
|
||||
}
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
@@ -115,6 +190,48 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setShippingMethodForGroup = useCallback((groupId: string, methodId: string) => {
|
||||
const methodLabel =
|
||||
methodId in SHIPPING_METHOD_LABELS_FA
|
||||
? SHIPPING_METHOD_LABELS_FA[methodId as ShippingMethodId]
|
||||
: methodId
|
||||
setState((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
shippingMethodByGroupId: {
|
||||
...prev.shippingMethodByGroupId,
|
||||
[groupId]: methodId,
|
||||
},
|
||||
shippingQuotes: prev.shippingQuotes.map((quote) =>
|
||||
quote.key === groupId
|
||||
? { ...quote, methodId, methodLabel }
|
||||
: quote,
|
||||
),
|
||||
}
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setShippingMethodByGroupId = useCallback(
|
||||
(shippingMethodByGroupId: Record<string, string>) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, shippingMethodByGroupId }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const setShippingQuotes = useCallback((shippingQuotes: CheckoutShippingQuote[]) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, shippingQuotes }
|
||||
persistPartial(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setDiscountCode = useCallback((discountCode: string) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev, discountCode }
|
||||
@@ -136,16 +253,39 @@ export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const shippingTotal = useMemo(
|
||||
() =>
|
||||
state.deliveryMode === 'pickup'
|
||||
? 0
|
||||
: state.shippingQuotes.reduce((sum, quote) => sum + quote.amount, 0),
|
||||
[state.deliveryMode, state.shippingQuotes],
|
||||
)
|
||||
|
||||
const value = useMemo<CheckoutContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
shippingTotal,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
resetCheckout,
|
||||
}),
|
||||
[state, setDeliveryMode, setSelectedAddressId, setDiscountCode, setPlacedOrder, resetCheckout],
|
||||
[
|
||||
state,
|
||||
shippingTotal,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
resetCheckout,
|
||||
],
|
||||
)
|
||||
|
||||
return <CheckoutContext.Provider value={value}>{children}</CheckoutContext.Provider>
|
||||
|
||||
@@ -154,6 +154,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',
|
||||
|
||||
'favorites.title': 'My Favorites',
|
||||
'favorites.subtitle': 'Products you have saved for later.',
|
||||
@@ -576,6 +582,12 @@ const fa: Record<MessageKey, string> = {
|
||||
'orderItems.item': 'قلم',
|
||||
'orderItems.itemsPlural': 'قلم',
|
||||
'orderItems.close': 'بستن',
|
||||
'orderItems.delivery': 'ارسال',
|
||||
'orderItems.pickup': 'تحویل حضوری',
|
||||
'orderItems.subtotal': 'جمع جزء',
|
||||
'orderItems.shipping': 'هزینه ارسال',
|
||||
'orderItems.discount': 'تخفیف',
|
||||
'orderItems.freeShipping': 'ارسال رایگان',
|
||||
|
||||
'favorites.title': 'علاقهمندیها',
|
||||
'favorites.subtitle': 'محصولاتی که برای بعد ذخیره کردهاید.',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, use
|
||||
import { useT } from '../i18n/useT'
|
||||
import { safeRedirectPath } from '../lib/redirect'
|
||||
import { BackgroundOrbs } from '../components/BackgroundOrbs'
|
||||
import { syncGuestCartToServer } from '../services/syncGuestCart'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
@@ -49,6 +50,17 @@ export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||
|
||||
async function finishAuthAndNavigate() {
|
||||
if (redirectTo.startsWith('/checkout')) {
|
||||
try {
|
||||
await syncGuestCartToServer()
|
||||
} catch {
|
||||
// Cart sync is best-effort; checkout will retry
|
||||
}
|
||||
}
|
||||
navigate(redirectTo)
|
||||
}
|
||||
const { login, loginWithOtp, establishSession } = useAuth()
|
||||
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||||
const tenantDomain = getTenantDomain()
|
||||
@@ -195,7 +207,7 @@ export function LoginPage() {
|
||||
|
||||
try {
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('login.error.signIn'))
|
||||
} finally {
|
||||
@@ -337,7 +349,7 @@ export function LoginPage() {
|
||||
if (data.existingAccount) {
|
||||
showToast(t('signup.toast.linkedExisting'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
} finally {
|
||||
@@ -438,7 +450,7 @@ export function LoginPage() {
|
||||
if (result.passwordUpdated) {
|
||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
await finishAuthAndNavigate()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('otp.error.signIn'))
|
||||
} finally {
|
||||
|
||||
@@ -189,7 +189,7 @@ export function CheckoutCartStep() {
|
||||
|
||||
async function handleContinue() {
|
||||
if (!isAuthenticated) {
|
||||
navigate(`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
navigate(`/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
state: { from: '/checkout/delivery' },
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { MapPin, Store } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import { listAddresses, type UserAddress } from '../../services/addressService'
|
||||
import { getCart, type CartItem } from '../../services/cartService'
|
||||
import { listIranProvinces, type CityOption } from '../../services/citiesService'
|
||||
import {
|
||||
findGroupForCategoryIds,
|
||||
getPublicShippingConfig,
|
||||
resolveProvinceAmount,
|
||||
SHIPPING_METHOD_LABELS_FA,
|
||||
type PublicShippingConfig,
|
||||
type ShippingMethodId,
|
||||
} from '../../services/shippingCostService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import {
|
||||
CheckoutAddAddressRow,
|
||||
} from '../../components/checkout/CheckoutAddAddressPanel'
|
||||
@@ -13,12 +24,54 @@ import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
const STATIC_GROUP_KEY = 'static'
|
||||
const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه'
|
||||
const UNGROUPED_KEY = 'ungrouped'
|
||||
|
||||
interface ShippingGroupQuote {
|
||||
key: string
|
||||
title: string
|
||||
methods: ShippingMethodId[]
|
||||
amount: number
|
||||
items: CartItem[]
|
||||
}
|
||||
|
||||
function resolveProvinceId(
|
||||
provinces: CityOption[],
|
||||
provinceName: string | null | undefined,
|
||||
) {
|
||||
const needle = provinceName?.trim()
|
||||
if (!needle) return null
|
||||
const match = provinces.find(
|
||||
(item) =>
|
||||
item.nameFa === needle ||
|
||||
item.nameEn === needle ||
|
||||
item.slug === needle,
|
||||
)
|
||||
return match?.id ?? null
|
||||
}
|
||||
|
||||
function quoteTitleForGroup(isDefault: boolean, nameFa: string, nameEn: string) {
|
||||
return isDefault ? STORE_SHIPPING_TITLE : nameFa || nameEn
|
||||
}
|
||||
|
||||
export function CheckoutDeliveryStep() {
|
||||
const navigate = useNavigate()
|
||||
const { user, isLoading: authLoading } = useAuth()
|
||||
const { deliveryMode, selectedAddressId, setDeliveryMode, setSelectedAddressId } =
|
||||
useCheckout()
|
||||
const {
|
||||
deliveryMode,
|
||||
selectedAddressId,
|
||||
shippingMethodByGroupId,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setShippingMethodForGroup,
|
||||
setShippingMethodByGroupId,
|
||||
setShippingQuotes,
|
||||
} = useCheckout()
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([])
|
||||
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([])
|
||||
const [shippingConfig, setShippingConfig] = useState<PublicShippingConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
@@ -35,24 +88,32 @@ export function CheckoutDeliveryStep() {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listAddresses(controller.signal)
|
||||
const [addressData, provinceItems, cartData, shippingData] = await Promise.all([
|
||||
listAddresses(controller.signal),
|
||||
listIranProvinces(controller.signal),
|
||||
getCart(controller.signal),
|
||||
getPublicShippingConfig(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setAddresses(data.items)
|
||||
setAddresses(addressData.items)
|
||||
setProvinces(provinceItems)
|
||||
setCartItems(cartData.cart.items)
|
||||
setShippingConfig(shippingData)
|
||||
|
||||
const current = selectedAddressIdRef.current
|
||||
if (data.items.length === 0) {
|
||||
if (addressData.items.length === 0) {
|
||||
if (current !== null) setSelectedAddressId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stillValid = current !== null && data.items.some((item) => item.id === current)
|
||||
const stillValid = current !== null && addressData.items.some((item) => item.id === current)
|
||||
if (!stillValid) {
|
||||
setSelectedAddressId(data.items[0].id)
|
||||
setSelectedAddressId(addressData.items[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات ارسال ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -62,19 +123,183 @@ export function CheckoutDeliveryStep() {
|
||||
return () => controller.abort()
|
||||
}, [user, setSelectedAddressId])
|
||||
|
||||
async function handleAddressSaved() {
|
||||
async function handleAddressSaved(created?: { id: string }) {
|
||||
setError('')
|
||||
try {
|
||||
const data = await listAddresses()
|
||||
setAddresses(data.items)
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
if (created?.id && data.items.some((item) => item.id === created.id)) {
|
||||
setSelectedAddressId(created.id)
|
||||
} else {
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((item) => item.id === selectedAddressId) ?? null,
|
||||
[addresses, selectedAddressId],
|
||||
)
|
||||
|
||||
const provinceId = useMemo(
|
||||
() => resolveProvinceId(provinces, selectedAddress?.province),
|
||||
[provinces, selectedAddress?.province],
|
||||
)
|
||||
|
||||
const shippingQuotes = useMemo((): ShippingGroupQuote[] => {
|
||||
if (deliveryMode !== 'delivery' || !selectedAddress) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!shippingConfig?.enabled) {
|
||||
if (cartItems.length === 0) return []
|
||||
return [
|
||||
{
|
||||
key: UNGROUPED_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: 0,
|
||||
items: cartItems,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (shippingConfig.staticShippingEnabled) {
|
||||
return [
|
||||
{
|
||||
key: STATIC_GROUP_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: resolveProvinceAmount(
|
||||
shippingConfig.staticShippingAmount,
|
||||
shippingConfig.staticShippingProvinceExceptions,
|
||||
provinceId,
|
||||
),
|
||||
items: cartItems,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const buckets = new Map<string, ShippingGroupQuote>()
|
||||
const ungrouped: CartItem[] = []
|
||||
const defaultGroup =
|
||||
shippingConfig.items.find((group) => group.isDefault) ?? null
|
||||
|
||||
for (const item of cartItems) {
|
||||
const matched = findGroupForCategoryIds(
|
||||
shippingConfig.items,
|
||||
item.categoryIds ?? [],
|
||||
)
|
||||
const group = matched ?? defaultGroup
|
||||
if (!group) {
|
||||
ungrouped.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = buckets.get(group.id)
|
||||
if (existing) {
|
||||
existing.items.push(item)
|
||||
} else {
|
||||
buckets.set(group.id, {
|
||||
key: group.id,
|
||||
title: quoteTitleForGroup(group.isDefault, group.nameFa, group.nameEn),
|
||||
methods: group.methods,
|
||||
amount: resolveProvinceAmount(
|
||||
group.defaultAmount,
|
||||
group.provinceExceptions,
|
||||
provinceId,
|
||||
),
|
||||
items: [item],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
buckets.set(UNGROUPED_KEY, {
|
||||
key: UNGROUPED_KEY,
|
||||
title: STORE_SHIPPING_TITLE,
|
||||
methods: [],
|
||||
amount: 0,
|
||||
items: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return [...buckets.values()]
|
||||
}, [cartItems, deliveryMode, provinceId, selectedAddress, shippingConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (shippingQuotes.length === 0) return
|
||||
|
||||
const next: Record<string, string> = { ...shippingMethodByGroupId }
|
||||
let changed = false
|
||||
|
||||
for (const quote of shippingQuotes) {
|
||||
if (!quote.methods.length) {
|
||||
if (next[quote.key]) {
|
||||
delete next[quote.key]
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!next[quote.key] || !quote.methods.includes(next[quote.key] as ShippingMethodId)) {
|
||||
next[quote.key] = quote.methods[0]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(next)) {
|
||||
if (!shippingQuotes.some((quote) => quote.key === key)) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) setShippingMethodByGroupId(next)
|
||||
}, [shippingQuotes, shippingMethodByGroupId, setShippingMethodByGroupId])
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveryMode === 'pickup') {
|
||||
setShippingQuotes([])
|
||||
return
|
||||
}
|
||||
setShippingQuotes(
|
||||
shippingQuotes.map((quote) => {
|
||||
const methodId = shippingMethodByGroupId[quote.key] ?? quote.methods[0] ?? null
|
||||
return {
|
||||
key: quote.key,
|
||||
title: quote.title,
|
||||
methodId,
|
||||
methodLabel: methodId
|
||||
? SHIPPING_METHOD_LABELS_FA[methodId as ShippingMethodId] ?? methodId
|
||||
: null,
|
||||
amount: quote.amount,
|
||||
itemNames: quote.items.map(
|
||||
(item) => item.productNameFa || item.productTitle,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [deliveryMode, shippingQuotes, shippingMethodByGroupId, setShippingQuotes])
|
||||
|
||||
const shippingTotal = useMemo(
|
||||
() => shippingQuotes.reduce((sum, quote) => sum + quote.amount, 0),
|
||||
[shippingQuotes],
|
||||
)
|
||||
|
||||
const shippingReady =
|
||||
deliveryMode === 'pickup' ||
|
||||
(Boolean(selectedAddressId) &&
|
||||
shippingQuotes.every(
|
||||
(quote) =>
|
||||
quote.methods.length === 0 || Boolean(shippingMethodByGroupId[quote.key]),
|
||||
))
|
||||
|
||||
const canContinue =
|
||||
deliveryMode === 'pickup' || (deliveryMode === 'delivery' && selectedAddressId)
|
||||
deliveryMode === 'pickup' ||
|
||||
(deliveryMode === 'delivery' && Boolean(selectedAddressId) && shippingReady)
|
||||
|
||||
if (authLoading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
@@ -83,7 +308,7 @@ export function CheckoutDeliveryStep() {
|
||||
if (!user) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
to={`/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
replace
|
||||
state={{ from: '/checkout/delivery' }}
|
||||
/>
|
||||
@@ -97,9 +322,9 @@ export function CheckoutDeliveryStep() {
|
||||
onClick={() => navigate('/checkout/cart')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className={styles.stepHeadingRow}>
|
||||
<h1 className={styles.stepTitle}>نحوه دریافت</h1>
|
||||
<p className={styles.stepDesc}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
<p className={styles.stepDescInline}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.deliveryOptions}>
|
||||
@@ -198,13 +423,92 @@ export function CheckoutDeliveryStep() {
|
||||
<CheckoutAddAddressRow onClick={() => setShowAddModal(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAddressId && !loading ? (
|
||||
<div className={styles.shippingSection}>
|
||||
<h2 className={styles.shippingSectionTitle}>روش و هزینه ارسال</h2>
|
||||
|
||||
{shippingQuotes.length === 0 ? (
|
||||
<p className={styles.addressEmptyHint}>
|
||||
برای این سفارش روش ارسال فعالی تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{shippingQuotes.map((quote) => (
|
||||
<div key={quote.key} className={styles.shippingGroupCard}>
|
||||
<div className={styles.shippingGroupHeader}>
|
||||
<span className={styles.shippingGroupTitle}>{quote.title}</span>
|
||||
<span
|
||||
className={[
|
||||
styles.shippingGroupAmount,
|
||||
quote.amount > 0 ? '' : styles.shippingGroupAmountFree,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{quote.amount > 0
|
||||
? formatIrtPrice(quote.amount)
|
||||
: 'ارسال رایگان'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{quote.items.length > 0 ? (
|
||||
<ul className={styles.shippingItemList}>
|
||||
{quote.items.map((item) => (
|
||||
<li key={item.id} className={styles.shippingItemRow}>
|
||||
<span className={styles.shippingItemName}>
|
||||
{item.productNameFa || item.productTitle}
|
||||
</span>
|
||||
<span className={styles.shippingItemQty}>
|
||||
{item.quantity.toLocaleString('fa-IR')} عدد
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{quote.methods.length > 0 ? (
|
||||
<div className={styles.shippingMethodGrid} role="group">
|
||||
{quote.methods.map((method) => {
|
||||
const active = shippingMethodByGroupId[quote.key] === method
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={[
|
||||
styles.shippingMethodChip,
|
||||
active ? styles.shippingMethodChipActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setShippingMethodForGroup(quote.key, method)}
|
||||
>
|
||||
{SHIPPING_METHOD_LABELS_FA[method]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{shippingQuotes.length > 1 ? (
|
||||
<div className={styles.shippingTotalRow}>
|
||||
<span>جمع هزینه ارسال</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(shippingTotal)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CheckoutAddAddressModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSaved={() => void handleAddressSaved()}
|
||||
onSaved={(address) => void handleAddressSaved(address)}
|
||||
/>
|
||||
|
||||
<CheckoutActionBar
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { RouteLoader } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
|
||||
import {
|
||||
CheckoutStepper,
|
||||
CheckoutStepSidePanel,
|
||||
type CheckoutStepId,
|
||||
} from '../../components/checkout/CheckoutStepper'
|
||||
import { loginPathWithRedirect, safeRedirectPath } from '../../lib/redirect'
|
||||
|
||||
/** Cart and checkout login are public. Delivery, payment, and result require auth. */
|
||||
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
|
||||
import layoutStyles from '../../components/checkout/CheckoutLayout.module.css'
|
||||
|
||||
function stepFromPath(pathname: string): CheckoutStepId {
|
||||
if (pathname.endsWith('/login')) return 'login'
|
||||
@@ -31,17 +33,6 @@ export function CheckoutFlow() {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
|
||||
const returnTo = `${location.pathname}${location.search}`
|
||||
return (
|
||||
<Navigate
|
||||
to={loginPathWithRedirect(returnTo, '/checkout/login')}
|
||||
replace
|
||||
state={{ from: returnTo }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated && step === 'login') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const fromQuery = safeRedirectPath(params.get('redirect'), '')
|
||||
@@ -50,17 +41,38 @@ export function CheckoutFlow() {
|
||||
return <Navigate to={destination} replace />
|
||||
}
|
||||
|
||||
// Logged-out users use the real /login page (not an inline checkout login).
|
||||
if (!isAuthenticated && step !== 'cart') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const returnTo =
|
||||
step === 'login'
|
||||
? safeRedirectPath(params.get('redirect'), '/checkout/delivery')
|
||||
: `${location.pathname}${location.search}`
|
||||
return (
|
||||
<Navigate
|
||||
to={loginPathWithRedirect(returnTo, '/login')}
|
||||
replace
|
||||
state={{ from: returnTo }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hideStepper =
|
||||
location.pathname === '/checkout/success' ||
|
||||
location.pathname === '/checkout/failed' ||
|
||||
location.pathname === '/checkout/result'
|
||||
|
||||
return (
|
||||
<>
|
||||
{step !== 'login' && !hideStepper && (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
)}
|
||||
<Outlet />
|
||||
</>
|
||||
<div className={layoutStyles.shell}>
|
||||
{!hideStepper ? (
|
||||
<CheckoutStepSidePanel current={step} isAuthenticated={isAuthenticated} />
|
||||
) : null}
|
||||
<div className={layoutStyles.card}>
|
||||
{!hideStepper ? (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
) : null}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,15 @@ import styles from './CheckoutSteps.module.css'
|
||||
export function CheckoutPaymentStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { deliveryMode, selectedAddressId, discountCode, setDiscountCode, setPlacedOrder } =
|
||||
useCheckout()
|
||||
const {
|
||||
deliveryMode,
|
||||
selectedAddressId,
|
||||
shippingQuotes,
|
||||
shippingTotal,
|
||||
discountCode,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
} = useCheckout()
|
||||
const [subtotal, setSubtotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -153,21 +160,37 @@ export function CheckoutPaymentStep() {
|
||||
? `${window.location.origin}/checkout/result`
|
||||
: undefined
|
||||
|
||||
const shippingGroups =
|
||||
deliveryMode === 'delivery'
|
||||
? shippingQuotes.map((quote) => ({
|
||||
groupId: quote.key,
|
||||
title: quote.title,
|
||||
methodId: quote.methodId ?? undefined,
|
||||
methodLabel: quote.methodLabel ?? undefined,
|
||||
amount: quote.amount,
|
||||
itemNames: quote.itemNames,
|
||||
}))
|
||||
: []
|
||||
|
||||
const payload =
|
||||
deliveryMode === 'pickup'
|
||||
? {
|
||||
deliveryMode: 'pickup' as const,
|
||||
shippingAddress: {
|
||||
province: 'Pickup',
|
||||
city: 'Store',
|
||||
address: 'In-store pickup',
|
||||
postalCode: '0000000000',
|
||||
},
|
||||
shippingGroups: [],
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
}
|
||||
: {
|
||||
deliveryMode: 'delivery' as const,
|
||||
addressId: selectedAddressId!,
|
||||
shippingGroups,
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
returnUrl,
|
||||
payment,
|
||||
@@ -351,13 +374,25 @@ export function CheckoutPaymentStep() {
|
||||
<span>جمع جزء</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>هزینه ارسال</span>
|
||||
<span className={styles.priceEn}>
|
||||
{deliveryMode === 'pickup'
|
||||
? '—'
|
||||
: shippingTotal <= 0
|
||||
? 'ارسال رایگان'
|
||||
: formatIrtPrice(shippingTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تخفیف</span>
|
||||
<span className={styles.priceEn}>۰ IRT</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
<span className={styles.priceEn}>
|
||||
{formatIrtPrice(subtotal + (deliveryMode === 'pickup' ? 0 : shippingTotal))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.stepHeadingRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.stepDescInline {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -271,18 +285,18 @@
|
||||
.summaryRowTotal {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e4e4e7;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.28));
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18181b;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summaryRowTotal .priceEn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.deliveryOptions {
|
||||
@@ -416,6 +430,134 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shippingSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.shippingSectionTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingGroupCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.shippingGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shippingGroupTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shippingGroupAmount {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.shippingGroupAmountFree {
|
||||
font-family: inherit;
|
||||
direction: rtl;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.shippingItemList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shippingItemRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.shippingItemName {
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shippingItemQty {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shippingMethodGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shippingMethodChip {
|
||||
min-height: 34px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.shippingMethodChip:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingMethodChipActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.shippingTotalRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--glass-border, rgba(148, 163, 184, 0.28));
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CartItem {
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
categoryIds?: string[]
|
||||
sku: string
|
||||
selections: CartItemSelection[]
|
||||
label: string
|
||||
@@ -68,9 +69,20 @@ export interface CheckoutPaymentResult {
|
||||
redirect: PaymentRedirect
|
||||
}
|
||||
|
||||
export interface CheckoutShippingGroupInput {
|
||||
groupId: string
|
||||
title?: string
|
||||
methodId?: string
|
||||
methodLabel?: string
|
||||
amount?: number
|
||||
itemNames?: string[]
|
||||
}
|
||||
|
||||
export interface CheckoutInput {
|
||||
addressId?: string
|
||||
shippingAddress?: ShippingAddressInput
|
||||
deliveryMode?: 'delivery' | 'pickup'
|
||||
shippingGroups?: CheckoutShippingGroupInput[]
|
||||
customerNotes?: string
|
||||
/** Absolute URL for bank callback return (required for e_payment_gate). */
|
||||
returnUrl?: string
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
|
||||
export const SHIPPING_METHOD_IDS = [
|
||||
'post',
|
||||
'tipax',
|
||||
'mahex',
|
||||
'freight',
|
||||
'air',
|
||||
] as const
|
||||
|
||||
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number]
|
||||
|
||||
export const SHIPPING_METHOD_LABELS_FA: Record<ShippingMethodId, string> = {
|
||||
post: 'پست',
|
||||
tipax: 'تیپاکس',
|
||||
mahex: 'ماهکس',
|
||||
freight: 'باربری',
|
||||
air: 'ارسال هوایی',
|
||||
}
|
||||
|
||||
export interface PublicShippingCostGroup {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
description: string | null
|
||||
methods: ShippingMethodId[]
|
||||
defaultAmount: number
|
||||
isDefault: boolean
|
||||
categoryIds: string[]
|
||||
provinceExceptions?: Array<{ provinceId: string; amount: number }>
|
||||
}
|
||||
|
||||
export interface PublicShippingConfig {
|
||||
enabled: boolean
|
||||
staticShippingEnabled: boolean
|
||||
staticShippingAmount: number
|
||||
staticShippingProvinceExceptions: Array<{ provinceId: string; amount: number }>
|
||||
items: PublicShippingCostGroup[]
|
||||
}
|
||||
|
||||
export async function getPublicShippingConfig(signal?: AbortSignal) {
|
||||
const host = encodeURIComponent(getTenantDomain())
|
||||
return apiRequest<PublicShippingConfig>(`/tenants/${host}/shipping-cost-groups`, {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveProvinceAmount(
|
||||
defaultAmount: number,
|
||||
exceptions: Array<{ provinceId: string; amount: number }> | undefined,
|
||||
provinceId: string | null,
|
||||
) {
|
||||
if (!provinceId || !exceptions?.length) return defaultAmount
|
||||
const match = exceptions.find((item) => item.provinceId === provinceId)
|
||||
return match ? match.amount : defaultAmount
|
||||
}
|
||||
|
||||
export function findGroupForCategoryIds(
|
||||
groups: PublicShippingCostGroup[],
|
||||
categoryIds: string[],
|
||||
) {
|
||||
const categorySet = new Set(categoryIds)
|
||||
const byCategory = groups.filter((group) =>
|
||||
group.categoryIds.some((id) => categorySet.has(id)),
|
||||
)
|
||||
if (byCategory.length === 0) return null
|
||||
return byCategory.find((group) => !group.isDefault) ?? byCategory[0] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user