Add shipping cost management and checkout shipping fees.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-02 21:52:47 +03:30
co-authored by Cursor
parent 95390933a7
commit c59ca4444f
38 changed files with 5353 additions and 98 deletions
+2
View File
@@ -37,6 +37,7 @@ import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
import { StoreSettingsPage } from './pages/StoreSettingsPage'
import { StoreShippingPage } from './pages/StoreShippingPage'
import { StoreInstallmentsPage } from './pages/StoreInstallmentsPage'
import { AddStoreInstallmentPage } from './pages/AddStoreInstallmentPage'
import { BlogPage } from './pages/BlogPage'
@@ -111,6 +112,7 @@ function App() {
<Route path="store" element={<StorePage />} />
<Route path="store/items" element={<StoreItemsPage />} />
<Route path="store/orders" element={<OrdersPage />} />
<Route path="store/shipping" element={<StoreShippingPage />} />
<Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/installments" element={<StoreInstallmentsPage />} />
<Route path="store/installments/new" element={<AddStoreInstallmentPage />} />
@@ -0,0 +1,134 @@
.wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.wrapperOpen {
z-index: 20;
}
.treePanel {
position: static;
max-height: min(280px, 40vh);
overflow-y: auto;
margin-top: 0;
box-shadow: none;
}
.treeRow {
padding: 3px 6px;
min-height: 0;
}
.selectBtn {
align-items: center;
gap: 8px;
padding: 1px 0;
}
.checkbox {
width: 15px;
height: 15px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.55);
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: var(--primary);
background: var(--card-media-bg);
}
.checkboxChecked {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.namePair {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
font-size: 13px;
line-height: 1.25;
}
.nameEn {
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.nameFa {
font-weight: 600;
color: var(--text-primary);
}
.nameSep {
color: var(--text-muted);
font-weight: 500;
}
.selectedList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(160px, 22vh);
overflow-y: auto;
}
.selectedRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(148, 163, 184, 0.06);
direction: rtl;
}
.selectedRow .namePair {
flex: 0 1 auto;
min-width: 0;
justify-content: flex-end;
}
.selectedRow .nameEn,
.selectedRow .nameFa {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeBtn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.removeBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.18);
color: var(--text-primary);
}
.removeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,341 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import type { CategoryTreeItem } from './CategoryTreeSelect'
import treeStyles from './CategoryTree.module.css'
import selectStyles from './CategoryTreeSelect.module.css'
import styles from './CategoryTreeMultiSelect.module.css'
interface CategoryTreeMultiSelectProps {
categories: CategoryTreeItem[]
selectedIds: string[]
onToggle: (id: string) => void
placeholder?: string
disabled?: boolean
id?: string
}
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
return categories
.filter((category) => category.parentId === parentId)
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
}
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
const byId = new Map(categories.map((category) => [category.id, category]))
const ids: string[] = []
let current = byId.get(id)
while (current?.parentId) {
ids.push(current.parentId)
current = byId.get(current.parentId)
}
return ids
}
function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase()
if (!q) return true
return (
category.nameEn.toLowerCase().includes(q) ||
category.nameFa.toLowerCase().includes(q) ||
category.nameFa.includes(query.trim())
)
}
function visibleIdsForQuery(
categories: CategoryTreeItem[],
query: string,
): Set<string> | null {
const q = query.trim()
if (!q) return null
const matched = categories.filter((category) => categoryMatches(category, q))
const visible = new Set<string>()
for (const category of matched) {
visible.add(category.id)
for (const ancestorId of getAncestorIds(categories, category.id)) {
visible.add(ancestorId)
}
}
return visible
}
function CategoryNamePair({ category }: { category: CategoryTreeItem }) {
const nameEn = category.nameEn?.trim() || ''
const nameFa = category.nameFa?.trim() || ''
return (
<span className={styles.namePair} dir="ltr">
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{nameEn && nameFa ? <span className={styles.nameSep}>·</span> : null}
{nameFa ? (
<span className={styles.nameFa} dir="rtl" lang="fa">
{nameFa}
</span>
) : null}
</span>
)
}
function TreeNodes({
categories,
parentId,
selectedIds,
expandedIds,
visibleIds,
forceExpanded,
disabled,
onToggleExpand,
onToggleSelect,
}: {
categories: CategoryTreeItem[]
parentId: string | null
selectedIds: Set<string>
expandedIds: Set<string>
visibleIds: Set<string> | null
forceExpanded: boolean
disabled: boolean
onToggleExpand: (id: string) => void
onToggleSelect: (id: string) => void
}) {
const t = useT()
const nodes = getChildren(categories, parentId).filter(
(category) => !visibleIds || visibleIds.has(category.id),
)
return (
<>
{nodes.map((category) => {
const childCount = getChildren(categories, category.id).some(
(child) => !visibleIds || visibleIds.has(child.id),
)
const expanded = forceExpanded || expandedIds.has(category.id)
const selected = selectedIds.has(category.id)
return (
<div key={category.id}>
<div
className={`${selectStyles.row} ${styles.treeRow} ${selected ? selectStyles.rowSelected : ''}`}
>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={selectStyles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggleExpand(category.id)}
disabled={disabled}
>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
<span className={selectStyles.toggleSpacer} />
)}
<button
type="button"
className={`${selectStyles.selectBtn} ${styles.selectBtn}`}
onClick={() => onToggleSelect(category.id)}
disabled={disabled}
aria-pressed={selected}
>
<span
className={`${styles.checkbox} ${selected ? styles.checkboxChecked : ''}`}
aria-hidden="true"
>
{selected ? '✓' : ''}
</span>
<CategoryNamePair category={category} />
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
selectedIds={selectedIds}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
disabled={disabled}
onToggleExpand={onToggleExpand}
onToggleSelect={onToggleSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
export function CategoryTreeMultiSelect({
categories,
selectedIds,
onToggle,
placeholder,
disabled = false,
id,
}: CategoryTreeMultiSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const containerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const searchPlaceholder = placeholder ?? t('categories.select.search')
const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query),
[categories, query],
)
const selectedCategories = useMemo(
() =>
selectedIds
.map((id) => categories.find((category) => category.id === id))
.filter((category): category is CategoryTreeItem => Boolean(category)),
[categories, selectedIds],
)
useEffect(() => {
setExpandedIds((prev) => {
const next = new Set(prev)
for (const id of selectedIds) {
for (const ancestorId of getAncestorIds(categories, id)) {
next.add(ancestorId)
}
}
return next
})
}, [categories, selectedIds])
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
function handleToggleExpand(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
const searching = query.trim().length > 0
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
return (
<div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
<Search size={16} className={selectStyles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={selectStyles.input}
disabled={disabled}
placeholder={searchPlaceholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
onClick={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{query ? (
<button
type="button"
className={selectStyles.clearBtn}
onClick={() => setQuery('')}
aria-label={t('categories.select.clear')}
disabled={disabled}
>
<X size={14} />
</button>
) : null}
</div>
{open && !disabled ? (
<div className={`${selectStyles.panel} ${styles.treePanel}`}>
{!hasVisible ? (
<p className={selectStyles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
selectedIds={selectedSet}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
disabled={disabled}
onToggleExpand={handleToggleExpand}
onToggleSelect={onToggle}
/>
)}
</div>
) : null}
{selectedCategories.length > 0 ? (
<div className={styles.selectedList}>
{selectedCategories.map((category) => (
<div key={category.id} className={styles.selectedRow}>
<CategoryNamePair category={category} />
<button
type="button"
className={styles.removeBtn}
onClick={() => onToggle(category.id)}
disabled={disabled}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
</div>
))}
</div>
) : null}
</div>
)
}
@@ -140,6 +140,113 @@
color: var(--primary);
}
.shippingBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.05);
}
.shippingSectionTitle {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.addressText {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.shippingGroupList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.shippingGroupCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: var(--surface);
}
.shippingGroupHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.shippingGroupTitle {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.shippingGroupAmount {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.shippingMethod {
font-size: 11px;
font-weight: 600;
color: var(--primary);
}
.shippingItemNames {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.totalsBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.totalsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.totalsValue {
font-weight: 600;
color: var(--text-primary);
}
.totalsRowGrand {
padding-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
font-weight: 700;
color: var(--text-primary);
}
.empty {
font-size: 13px;
color: var(--text-muted);
@@ -30,6 +30,54 @@ function totalQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
const root = asRecord(shippingAddress) ?? {}
const deliveryMode =
root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
? root.deliveryMode
: root.province === 'Pickup'
? 'pickup'
: 'delivery'
const shipping = asRecord(root.shipping)
const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
const groups = groupsRaw
.map((entry) => {
const row = asRecord(entry)
if (!row) return null
const groupId = typeof row.groupId === 'string' ? row.groupId : ''
if (!groupId) return null
return {
groupId,
title: typeof row.title === 'string' ? row.title : '',
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
itemNames: Array.isArray(row.itemNames)
? row.itemNames.filter((name): name is string => typeof name === 'string')
: [],
}
})
.filter((group): group is NonNullable<typeof group> => Boolean(group))
const addressParts = [
typeof root.province === 'string' ? root.province : '',
typeof root.city === 'string' ? root.city : '',
typeof root.address === 'string' ? root.address : '',
typeof root.postalCode === 'string' ? root.postalCode : '',
].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
return {
deliveryMode,
addressLine: addressParts.join(' · '),
groups,
}
}
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
@@ -105,6 +153,47 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</span>
</div>
{(() => {
const shippingInfo = parseOrderShipping(order.shippingAddress)
return (
<div className={styles.shippingBlock}>
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
{shippingInfo.deliveryMode === 'pickup' ? (
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
) : shippingInfo.addressLine ? (
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
) : null}
{shippingInfo.groups.length > 0 ? (
<ul className={styles.shippingGroupList}>
{shippingInfo.groups.map((group) => (
<li key={group.groupId} className={styles.shippingGroupCard}>
<div className={styles.shippingGroupHeader}>
<span className={styles.shippingGroupTitle}>{group.title}</span>
<span className={styles.shippingGroupAmount}>
{group.amount > 0
? formatIrtPrice(group.amount)
: t('orderItems.freeShipping')}
</span>
</div>
{group.methodLabel ? (
<div className={styles.shippingMethod}>{group.methodLabel}</div>
) : null}
{group.itemNames.length > 0 ? (
<ul className={styles.shippingItemNames}>
{group.itemNames.map((name) => (
<li key={`${group.groupId}-${name}`}>{name}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : null}
</div>
)
})()}
{order.items.length === 0 ? (
<p className={styles.empty}>{t('orderItems.empty')}</p>
) : (
@@ -144,15 +233,35 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div>
)}
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
<div className={styles.totalsBlock}>
<div className={styles.totalsRow}>
<span>{t('orderItems.subtotal')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
</div>
<div className={styles.totalsRow}>
<span>{t('orderItems.shipping')}</span>
<span className={styles.totalsValue}>
{order.shippingTotal > 0
? formatIrtPrice(order.shippingTotal)
: t('orderItems.freeShipping')}
</span>
</div>
{order.discountTotal > 0 ? (
<div className={styles.totalsRow}>
<span>{t('orderItems.discount')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
</div>
) : null}
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
<span>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div>
</div>
<div className={modalStyles.actions}>
@@ -0,0 +1,331 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
width: 100%;
max-width: 560px;
max-height: calc(100vh - 40px);
overflow: auto;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.closeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label,
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.hint {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.field input,
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
box-sizing: border-box;
}
.field input {
min-height: var(--field-height);
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field textarea {
resize: vertical;
min-height: calc(var(--field-height) + 24px);
}
.chipGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
cursor: pointer;
}
.chip:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.chipSelected {
background: rgba(var(--primary-rgb) / 0.12);
border-color: var(--primary);
color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb) / 0.2);
}
.chipFa {
font-weight: 600;
}
.chipEn {
font-size: 11px;
font-weight: 600;
opacity: 0.72;
font-family: var(--font-en), var(--font-ui);
letter-spacing: 0.01em;
}
.chipSelected .chipEn {
opacity: 0.85;
}
.amountRow {
display: flex;
align-items: center;
gap: 10px;
}
.amountRow input {
flex: 1;
min-width: 0;
}
.amountUnit {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.checkRow {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.06);
cursor: pointer;
user-select: none;
}
.checkRow input {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--primary);
cursor: pointer;
}
.checkText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.checkTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.checkHint {
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
direction: rtl;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
transition: transform 0.2s, box-shadow 0.2s;
}
.submitBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.submitBtn:disabled,
.cancelBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.overlayIn {
animation: overlayFadeIn 0.2s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.2s ease forwards;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
.modalOut {
animation: modalFadeOut 0.22s ease forwards;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalFadeOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
@@ -0,0 +1,294 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type {
ShippingCostGroup,
ShippingCostGroupFormPayload,
ShippingMethodId,
} from '../types/shippingCostGroup'
import { SHIPPING_METHOD_IDS } from '../types/shippingCostGroup'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './ShippingCostGroupModal.module.css'
const METHOD_EN_LABELS: Record<ShippingMethodId, string> = {
post: 'Post',
tipax: 'Tipax',
mahex: 'Mahex',
freight: 'Freight',
air: 'Air',
}
interface ShippingCostGroupModalProps {
open: boolean
onClose: () => void
onSubmit: (data: ShippingCostGroupFormPayload) => void
editingGroup?: ShippingCostGroup | null
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function ShippingCostGroupModal({
open,
onClose,
onSubmit,
editingGroup = null,
isSubmitting = false,
}: ShippingCostGroupModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [nameFa, setNameFa] = useState('')
const [nameEn, setNameEn] = useState('')
const [description, setDescription] = useState('')
const [methods, setMethods] = useState<ShippingMethodId[]>([])
const [defaultAmount, setDefaultAmount] = useState('')
const [isDefault, setIsDefault] = useState(false)
const [formError, setFormError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setNameFa(editingGroup?.nameFa ?? '')
setNameEn(editingGroup?.nameEn ?? '')
setDescription(editingGroup?.description ?? '')
setMethods(editingGroup?.methods ?? [])
setDefaultAmount(
editingGroup
? formatIrtInput(String(Math.round(editingGroup.defaultAmount)))
: '',
)
setIsDefault(editingGroup?.isDefault ?? false)
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, editingGroup, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting])
if (!mounted) return null
function toggleMethod(method: ShippingMethodId) {
setMethods((prev) =>
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method],
)
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setFormError('')
const trimmedFa = nameFa.trim()
const trimmedEn = nameEn.trim()
const amount = parseIrtInput(defaultAmount)
if (!trimmedFa || !trimmedEn) {
setFormError(t('store.shipping.form.errorRequired'))
return
}
if (!methods.length) {
setFormError(t('store.shipping.form.errorMethods'))
return
}
if (amount === null || amount < 0) {
setFormError(t('store.shipping.form.errorAmount'))
return
}
onSubmit({
nameFa: trimmedFa,
nameEn: trimmedEn,
description: description.trim() || undefined,
methods,
defaultAmount: amount,
isDefault,
})
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="shipping-group-modal-title" className={styles.title}>
{editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="shipping-name-fa">{t('store.shipping.form.nameFa')}</label>
<input
id="shipping-name-fa"
type="text"
dir="rtl"
className="faText"
placeholder={t('store.shipping.form.nameFaPlaceholder')}
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-name-en">{t('store.shipping.form.nameEn')}</label>
<input
id="shipping-name-en"
type="text"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-description">{t('store.shipping.form.description')}</label>
<textarea
id="shipping-description"
rows={3}
dir={isFa ? 'rtl' : 'ltr'}
placeholder={t('store.shipping.form.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('store.shipping.form.methods')}</span>
<p className={styles.hint}>{t('store.shipping.form.methodsHint')}</p>
<div className={styles.chipGrid} role="group" aria-label={t('store.shipping.form.methods')}>
{SHIPPING_METHOD_IDS.map((method) => {
const checked = methods.includes(method)
return (
<button
key={method}
type="button"
className={`${styles.chip} ${checked ? styles.chipSelected : ''}`}
onClick={() => toggleMethod(method)}
disabled={isSubmitting}
aria-pressed={checked}
>
<span className={styles.chipFa}>
{t(`store.shipping.method.${method}` as `store.shipping.method.${typeof method}`)}
</span>
<span className={styles.chipEn} dir="ltr" lang="en">
{METHOD_EN_LABELS[method]}
</span>
</button>
)
})}
</div>
</div>
<div className={styles.field}>
<label htmlFor="shipping-default-amount">
{t('store.shipping.form.defaultAmount')}
</label>
<p className={styles.hint}>{t('store.shipping.form.defaultAmountHint')}</p>
<div className={styles.amountRow}>
<input
id="shipping-default-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.defaultAmountPlaceholder')}
value={defaultAmount}
onChange={(e) => setDefaultAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting}
required
/>
<span className={styles.amountUnit}>IRT</span>
</div>
</div>
<label className={styles.checkRow} htmlFor="shipping-is-default">
<input
id="shipping-is-default"
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
disabled={isSubmitting}
/>
<span className={styles.checkText}>
<span className={styles.checkTitle}>
{t('store.shipping.form.isDefault')}
</span>
<span className={styles.checkHint}>
{t('store.shipping.form.isDefaultHint')}
</span>
</span>
</label>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editingGroup
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,264 @@
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: transparent;
transition: background 0.2s, opacity 0.2s, border-color 0.2s;
}
.row:hover {
background: rgba(var(--primary-rgb) / 0.04);
border-color: rgba(var(--primary-rgb) / 0.28);
}
.rowDisabled {
opacity: 0.55;
pointer-events: none;
}
.titleBlock {
flex: 1 1 320px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 2px;
}
.names {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nameFa {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nameSep {
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
}
.nameEn {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
}
.defaultBadge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
}
.description {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metrics {
flex: 0 1 auto;
max-width: 48%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-inline-start: auto;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
min-height: 32px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.16);
}
.chipLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.chipValue {
display: inline-flex;
align-items: baseline;
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
}
.chipNumber {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
line-height: 1.2;
}
.methodChip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
white-space: nowrap;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-inline-start: 12px;
padding-inline-start: 14px;
border-inline-start: 1px solid rgba(148, 163, 184, 0.28);
}
.controlBtn {
position: relative;
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.controlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exceptionBtn {
color: var(--primary);
}
.exceptionBadge {
position: absolute;
top: 2px;
inset-inline-end: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: white;
background: var(--primary);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.categoryBadge {
position: absolute;
top: 2px;
inset-inline-end: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: #0f172a;
background: #ffffff;
border: 1px solid rgba(148, 163, 184, 0.4);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
:global(html:not([data-theme='dark'])) .categoryBadge {
color: #0f172a;
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.5);
}
.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
@media (max-width: 900px) {
.row {
flex-wrap: wrap;
}
.titleBlock {
flex: 1 1 100%;
min-width: 0;
}
.metrics {
flex: 1 1 auto;
max-width: none;
order: 3;
margin-inline-start: 0;
justify-content: flex-start;
}
.controls {
margin-inline-start: auto;
padding-inline-start: 0;
border-inline-start: none;
}
}
@@ -0,0 +1,127 @@
import { Layers, MapPin, Pencil, Trash2 } from 'lucide-react'
import type { ShippingCostGroup, ShippingMethodId } from '../types/shippingCostGroup'
import { getGroupProvinceExceptions } from '../services/shippingCostGroupService'
import { useT } from '../i18n/useT'
import { formatIrtPrice } from '../utils/irtPrice'
import { Tooltip } from './Tooltip'
import styles from './ShippingCostGroupRow.module.css'
interface ShippingCostGroupRowProps {
group: ShippingCostGroup
disabled?: boolean
onEdit: (id: string) => void
onRemove: (id: string) => void
onExceptions: (id: string) => void
onCategories: (id: string) => void
}
function methodLabelKey(method: ShippingMethodId) {
return `store.shipping.method.${method}` as const
}
export function ShippingCostGroupRow({
group,
disabled = false,
onEdit,
onRemove,
onExceptions,
onCategories,
}: ShippingCostGroupRowProps) {
const t = useT()
const nameFa = group.nameFa?.trim() || ''
const nameEn = group.nameEn?.trim() || ''
const exceptionCount = getGroupProvinceExceptions(group).length
const categoryCount = group.categoryIds?.length ?? group.categories?.length ?? 0
return (
<div className={`${styles.row} ${disabled ? styles.rowDisabled : ''}`}>
<div className={styles.titleBlock}>
<div className={styles.names}>
{nameFa ? <span className={styles.nameFa}>{nameFa}</span> : null}
{nameFa && nameEn ? <span className={styles.nameSep}>·</span> : null}
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{group.isDefault ? (
<span className={styles.defaultBadge}>
{t('store.shipping.row.defaultBadge')}
</span>
) : null}
</div>
{group.description ? (
<p className={styles.description}>{group.description}</p>
) : null}
</div>
<div className={styles.metrics}>
<div className={styles.chip}>
<span className={styles.chipLabel}>{t('store.shipping.row.defaultAmount')}</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>{formatIrtPrice(group.defaultAmount)}</span>
</span>
</div>
{group.methods.map((method) => (
<div key={method} className={styles.methodChip}>
{t(methodLabelKey(method))}
</div>
))}
</div>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.categories')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onCategories(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.categories')}
>
<Layers size={16} />
{categoryCount > 0 ? (
<span className={styles.categoryBadge}>{categoryCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.exceptions')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.exceptionBtn}`}
onClick={() => onExceptions(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.exceptions')}
>
<MapPin size={16} />
{exceptionCount > 0 ? (
<span className={styles.exceptionBadge}>{exceptionCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onEdit(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,210 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
listProductCategories,
mapProductCategoryToUi,
} from '../services/productCategoryService'
import { CategoryTreeMultiSelect } from './CategoryTreeMultiSelect'
import type { CategoryTreeItem } from './CategoryTreeSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreSpecialItemsModal.module.css'
interface ShippingGroupCategoriesModalProps {
open: boolean
groupName: string
selectedCategoryIds: string[]
onClose: () => void
onSave: (categoryIds: string[]) => Promise<void> | void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function ShippingGroupCategoriesModal({
open,
groupName,
selectedCategoryIds,
onClose,
onSave,
isSubmitting = false,
}: ShippingGroupCategoriesModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<CategoryTreeItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setSelectedIds(selectedCategoryIds)
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when open toggles
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [open])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting && !saving) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting, saving])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listProductCategories(signal)
setCategories(
items.map(mapProductCategoryToUi).map((category) => ({
id: category.id,
parentId: category.parentId,
nameEn: category.nameEn,
nameFa: category.nameFa,
})),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupCategories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(categoryId: string) {
setSelectedIds((prev) =>
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
)
}
async function handleSave() {
setSaving(true)
setError('')
try {
await onSave(selectedIds)
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupCategories.errorSave'))
}
} finally {
setSaving(false)
}
}
const busy = isSubmitting || saving
const treeReady = useMemo(() => categories.length > 0, [categories.length])
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !busy && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-categories-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className={modalStyles.header}>
<div>
<h2 id="shipping-group-categories-title" className={modalStyles.title}>
{t('store.shipping.groupCategories.title')}
</h2>
<p className={modalStyles.subtitle}>
{t('store.shipping.groupCategories.subtitle', { name: groupName })}
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error ? <p className={styles.error}>{error}</p> : null}
{isLoading && !treeReady ? (
<p className={styles.empty}>{t('store.shipping.groupCategories.loading')}</p>
) : (
<CategoryTreeMultiSelect
categories={categories}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder={t('categories.select.search')}
disabled={busy}
/>
)}
<div className={styles.footer}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={busy}
>
{t('store.shipping.form.cancel')}
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={() => void handleSave()}
disabled={busy}
>
{busy
? t('store.shipping.form.saving')
: t('store.shipping.groupCategories.save')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,200 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
width: 100%;
max-width: 560px;
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
overflow: hidden;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.headerText {
flex: 1;
min-width: 0;
}
.title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
.headerActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.addBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
}
.addBtn:disabled,
.closeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.closeBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.body {
padding: 16px 20px 20px;
overflow: auto;
}
.empty {
margin: 0;
padding: 28px 8px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-sm);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.row:last-child {
border-bottom: none;
}
.province {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.amount {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.controls {
display: flex;
align-items: center;
gap: 2px;
}
.controlBtn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
}
.controlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Pencil, Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { CityOption } from '../services/citiesService'
import type { ShippingCostGroupProvinceException } from '../types/shippingCostGroup'
import { useT } from '../i18n/useT'
import { formatIrtPrice } from '../utils/irtPrice'
import { StaticShippingExceptionModal } from './StaticShippingExceptionModal'
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
import { Tooltip } from './Tooltip'
import styles from './ShippingGroupExceptionsModal.module.css'
interface ShippingGroupExceptionsModalProps {
open: boolean
onClose: () => void
groupName: string
provinces: CityOption[]
exceptions: ShippingCostGroupProvinceException[]
isSubmitting?: boolean
onSave: (items: Array<{ provinceId: string; amount: number }>) => Promise<void> | void
}
const ANIMATION_MS = 220
export function ShippingGroupExceptionsModal({
open,
onClose,
groupName,
provinces,
exceptions,
isSubmitting = false,
onSave,
}: ShippingGroupExceptionsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [items, setItems] = useState<ShippingCostGroupProvinceException[]>([])
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<ShippingCostGroupProvinceException | null>(null)
const [deleteTarget, setDeleteTarget] =
useState<ShippingCostGroupProvinceException | null>(null)
const [saving, setSaving] = useState(false)
const provinceNameById = useMemo(() => {
const map = new Map<string, string>()
for (const province of provinces) {
map.set(
province.id,
isFa
? province.nameFa || province.nameEn
: province.nameEn || province.nameFa,
)
}
return map
}, [provinces, isFa])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setAddOpen(false)
setEditing(null)
setDeleteTarget(null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (open) setItems(exceptions)
}, [open, exceptions])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !saving && !isSubmitting && !addOpen && !deleteTarget) {
onClose()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, saving, isSubmitting, addOpen, deleteTarget])
if (!mounted) return null
async function persist(next: Array<{ provinceId: string; amount: number }>) {
setSaving(true)
try {
await onSave(next)
} finally {
setSaving(false)
}
}
async function handleUpsert(data: { provinceId: string; amount: number }) {
const next = editing
? items.map((item) =>
item.provinceId === editing.provinceId
? { ...item, provinceId: data.provinceId, amount: data.amount }
: item,
)
: [
...items.filter((item) => item.provinceId !== data.provinceId),
{
id: `tmp-${data.provinceId}`,
provinceId: data.provinceId,
amount: data.amount,
},
]
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
setAddOpen(false)
setEditing(null)
}
async function handleDelete() {
if (!deleteTarget) return
const next = items.filter((item) => item.provinceId !== deleteTarget.provinceId)
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
setDeleteTarget(null)
}
const busy = saving || isSubmitting
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !busy && !addOpen && !deleteTarget && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="group-exceptions-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div className={styles.headerText}>
<h3 id="group-exceptions-modal-title" className={styles.title}>
{t('store.shipping.groupException.title')}
</h3>
<p className={styles.subtitle}>
{t('store.shipping.groupException.subtitle', { name: groupName })}
</p>
</div>
<div className={styles.headerActions}>
<Tooltip label={t('store.shipping.groupException.add')}>
<button
type="button"
className={styles.addBtn}
onClick={() => {
setEditing(null)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.groupException.add')}
>
<Plus size={18} />
</button>
</Tooltip>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
</div>
<div className={styles.body}>
{items.length === 0 ? (
<p className={styles.empty}>{t('store.shipping.groupException.empty')}</p>
) : (
<ul className={styles.list}>
{items.map((item) => (
<li key={item.provinceId} className={styles.row}>
<span className={styles.province}>
{provinceNameById.get(item.provinceId) ?? item.provinceId}
</span>
<span className={styles.amount}>{formatIrtPrice(item.amount)}</span>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => {
setEditing(item)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => setDeleteTarget(item)}
disabled={busy}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<StaticShippingExceptionModal
open={addOpen}
provinces={provinces}
usedProvinceIds={items.map((item) => item.provinceId)}
editing={
editing
? { provinceId: editing.provinceId, amount: editing.amount }
: null
}
isSubmitting={busy}
onClose={() => {
if (!busy) {
setAddOpen(false)
setEditing(null)
}
}}
onSubmit={(data) => void handleUpsert(data)}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('store.shipping.groupException.deleteTitle')}
message={
deleteTarget
? t('store.shipping.groupException.deleteMessage', {
name:
provinceNameById.get(deleteTarget.provinceId) ??
deleteTarget.provinceId,
})
: ''
}
onConfirm={() => void handleDelete()}
onCancel={() => !busy && setDeleteTarget(null)}
/>
</div>,
document.body,
)
}
@@ -0,0 +1,181 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 260;
padding: 20px;
}
.modal {
width: 100%;
max-width: 440px;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.field input:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.amountRow {
display: flex;
align-items: center;
gap: 10px;
}
.amountRow input {
flex: 1;
min-width: 0;
font-family: var(--font-en), var(--font-ui);
}
.amountUnit {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
direction: rtl;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
}
.submitBtn:disabled,
.cancelBtn:disabled,
.closeBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,196 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { CityOption } from '../services/citiesService'
import { useT } from '../i18n/useT'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './StaticShippingExceptionModal.module.css'
interface StaticShippingExceptionModalProps {
open: boolean
onClose: () => void
onSubmit: (data: { provinceId: string; amount: number }) => void
provinces: CityOption[]
usedProvinceIds: string[]
editing?: { provinceId: string; amount: number } | null
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function StaticShippingExceptionModal({
open,
onClose,
onSubmit,
provinces,
usedProvinceIds,
editing = null,
isSubmitting = false,
}: StaticShippingExceptionModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [provinceId, setProvinceId] = useState('')
const [amount, setAmount] = useState('')
const [formError, setFormError] = useState('')
const availableProvinces = useMemo(() => {
return provinces.filter(
(province) =>
province.id === editing?.provinceId ||
!usedProvinceIds.includes(province.id),
)
}, [provinces, usedProvinceIds, editing?.provinceId])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setProvinceId(editing?.provinceId ?? '')
setAmount(
editing
? formatIrtInput(String(Math.round(editing.amount)))
: '',
)
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, editing, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting])
if (!mounted) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setFormError('')
const parsedAmount = parseIrtInput(amount)
if (!provinceId) {
setFormError(t('store.shipping.static.exception.errorProvince'))
return
}
if (parsedAmount === null || parsedAmount < 0) {
setFormError(t('store.shipping.form.errorAmount'))
return
}
onSubmit({ provinceId, amount: parsedAmount })
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="static-exception-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="static-exception-modal-title" className={styles.title}>
{editing
? t('store.shipping.static.exception.editTitle')
: t('store.shipping.static.exception.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="static-exception-province">
{t('store.shipping.static.exception.province')}
</label>
<select
id="static-exception-province"
value={provinceId}
onChange={(e) => setProvinceId(e.target.value)}
disabled={isSubmitting || Boolean(editing)}
required
>
<option value="">{t('store.shipping.static.exception.provincePlaceholder')}</option>
{availableProvinces.map((province) => (
<option key={province.id} value={province.id}>
{isFa ? province.nameFa || province.nameEn : province.nameEn || province.nameFa}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="static-exception-amount">
{t('store.shipping.static.exception.amount')}
</label>
<div className={styles.amountRow}>
<input
id="static-exception-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.static.amountPlaceholder')}
value={amount}
onChange={(e) => setAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting}
required
/>
<span className={styles.amountUnit}>IRT</span>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editing
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
+213
View File
@@ -924,6 +924,105 @@ const en = {
'store.card.installments.link': 'Manage installments',
'store.card.settings.desc': 'Configure store preferences, pages and themes.',
'store.card.settings.link': 'View settings',
'store.shipping.page.subtitle':
'Set a flat shipping fee for all orders, or define cost groups for categories.',
'store.shipping.loading': 'Loading shipping settings…',
'store.shipping.empty': 'No shipping cost groups yet. Add your first one.',
'store.shipping.add': 'Add shipping cost group',
'store.shipping.errorLoad': 'Failed to load shipping settings.',
'store.shipping.errorSaveStatic': 'Failed to save static shipping fee.',
'store.shipping.errorDelete': 'Failed to delete shipping cost group.',
'store.shipping.deleteTitle': 'Delete shipping cost group',
'store.shipping.deleteMessage':
'Are you sure you want to delete "{name}"? This action cannot be undone.',
'store.shipping.toast.staticSaved': 'Static shipping fee saved.',
'store.shipping.toast.created': 'Shipping cost group created.',
'store.shipping.toast.updated': 'Shipping cost group updated.',
'store.shipping.toast.deleted': 'Shipping cost group deleted.',
'store.shipping.tooltip.edit': 'Edit',
'store.shipping.tooltip.remove': 'Delete',
'store.shipping.tooltip.exceptions': 'Province exceptions',
'store.shipping.tooltip.categories': 'Assign categories',
'store.shipping.row.defaultAmount': 'Default amount',
'store.shipping.groupException.title': 'Province exceptions',
'store.shipping.groupException.subtitle': 'Special rates for "{name}"',
'store.shipping.groupException.empty': 'No province exceptions yet.',
'store.shipping.groupException.add': 'Add province exception',
'store.shipping.groupException.deleteTitle': 'Delete province exception',
'store.shipping.groupException.deleteMessage':
'Remove the special shipping fee for "{name}"?',
'store.shipping.groupException.toast.saved': 'Province exceptions saved.',
'store.shipping.groupException.errorSave': 'Failed to save province exceptions.',
'store.shipping.groupCategories.title': 'Assign categories',
'store.shipping.groupCategories.subtitle': 'Categories using "{name}"',
'store.shipping.groupCategories.loading': 'Loading categories…',
'store.shipping.groupCategories.save': 'Save categories',
'store.shipping.groupCategories.toast.saved': 'Categories assigned.',
'store.shipping.groupCategories.errorLoad': 'Failed to load categories.',
'store.shipping.groupCategories.errorSave': 'Failed to save categories.',
'store.shipping.static.title': 'Static shipping fee for all orders',
'store.shipping.static.desc':
'When enabled, every order uses this flat fee and shipping cost groups are ignored. Zero means free shipping.',
'store.shipping.static.amount': 'Amount (IRT)',
'store.shipping.static.amountPlaceholder': 'e.g. 150,000',
'store.shipping.static.exception.title': 'Province exceptions',
'store.shipping.static.exception.desc':
'Override the flat fee for specific provinces.',
'store.shipping.static.exception.disabledHint':
'Enable the static shipping fee to manage province exceptions.',
'store.shipping.static.exception.empty': 'No province exceptions yet.',
'store.shipping.static.exception.add': 'Add province exception',
'store.shipping.static.exception.addTitle': 'Add province exception',
'store.shipping.static.exception.editTitle': 'Edit province exception',
'store.shipping.static.exception.province': 'Province',
'store.shipping.static.exception.provincePlaceholder': 'Select a province',
'store.shipping.static.exception.amount': 'Amount (IRT)',
'store.shipping.static.exception.errorProvince': 'Select a province.',
'store.shipping.static.exception.deleteTitle': 'Delete province exception',
'store.shipping.static.exception.deleteMessage':
'Remove the special shipping fee for "{name}"?',
'store.shipping.groups.title': 'Shipping cost groups',
'store.shipping.groups.desc':
'Create cost groups with shipping methods and a default fee. Assign them to categories later for checkout.',
'store.shipping.groups.disabledHint':
'Turn this on to use cost groups. The static shipping fee will be disabled.',
'store.shipping.form.addTitle': 'Add shipping cost group',
'store.shipping.form.editTitle': 'Edit shipping cost group',
'store.shipping.form.nameFa': 'Name (FA)',
'store.shipping.form.nameEn': 'Name (EN)',
'store.shipping.form.nameFaPlaceholder': 'مثلاً گروه لوازم خانگی',
'store.shipping.form.nameEnPlaceholder': 'e.g. Home appliances group',
'store.shipping.form.description': 'Description',
'store.shipping.form.descriptionPlaceholder': 'Notes for your team…',
'store.shipping.form.categories': 'Assigned categories',
'store.shipping.form.categoriesHint':
'Products in these categories use this shipping group.',
'store.shipping.form.categoriesSearch': 'Type 3+ characters to search categories',
'store.shipping.form.categoriesLoading': 'Loading categories…',
'store.shipping.form.errorCategoriesLoad': 'Failed to load categories.',
'store.shipping.form.methods': 'Available shipping methods',
'store.shipping.form.methodsHint': 'Select the couriers customers can choose for this group.',
'store.shipping.form.defaultAmount': 'Default amount (IRT)',
'store.shipping.form.defaultAmountHint':
'Applied to all provinces unless a city-specific amount is set later.',
'store.shipping.form.defaultAmountPlaceholder': 'e.g. 120,000',
'store.shipping.form.isDefault': 'Make this the default group',
'store.shipping.form.isDefaultHint':
'Categories with no assigned shipping group will use this group.',
'store.shipping.row.defaultBadge': 'Default',
'store.shipping.form.cancel': 'Cancel',
'store.shipping.form.save': 'Save',
'store.shipping.form.update': 'Update',
'store.shipping.form.saving': 'Saving…',
'store.shipping.form.errorRequired': 'Please fill in all required fields.',
'store.shipping.form.errorMethods': 'Select at least one shipping method.',
'store.shipping.form.errorAmount': 'Enter a valid amount in IRT.',
'store.shipping.form.errorSave': 'Failed to save shipping cost group.',
'store.shipping.method.post': 'Post',
'store.shipping.method.tipax': 'Tipax',
'store.shipping.method.mahex': 'Mahex',
'store.shipping.method.freight': 'Freight',
'store.shipping.method.air': 'Air shipping',
'store.installments.page.subtitle':
'Create loan and installment methods your store can offer customers.',
'store.installments.loading': 'Loading installment methods…',
@@ -1186,6 +1285,12 @@ const en = {
'orderItems.item': 'item',
'orderItems.itemsPlural': 'items',
'orderItems.close': 'Close',
'orderItems.delivery': 'Delivery',
'orderItems.pickup': 'In-store pickup',
'orderItems.subtotal': 'Subtotal',
'orderItems.shipping': 'Shipping',
'orderItems.discount': 'Discount',
'orderItems.freeShipping': 'Free shipping',
'orderTransactions.title': 'Transaction details',
'orderTransactions.empty': 'No payment transactions recorded for this order.',
@@ -2363,6 +2468,7 @@ const en = {
'title.storeItems': 'My Store Items',
'title.orders': 'My Orders',
'title.shoppingCards': 'Shopping Cards',
'title.shipping': 'Shipping Fees',
'title.installments': 'Installments & Facilities',
'title.addInstallment': 'Add Installment Method',
'title.editInstallment': 'Edit Installment Method',
@@ -3691,6 +3797,105 @@ const fa: Record<MessageKey, string> = {
'store.card.installments.link': 'مدیریت اقساط',
'store.card.settings.desc': 'ترجیحات، صفحات و ظاهر فروشگاه را پیکربندی کنید.',
'store.card.settings.link': 'مشاهده تنظیمات',
'store.shipping.page.subtitle':
'یک هزینه ارسال ثابت برای همه سفارش‌ها تنظیم کنید، یا گروه‌های هزینه برای دسته‌ها تعریف کنید.',
'store.shipping.loading': 'در حال بارگذاری تنظیمات ارسال…',
'store.shipping.empty': 'هنوز گروه هزینه ارسالی تعریف نشده. اولین مورد را اضافه کنید.',
'store.shipping.add': 'افزودن گروه هزینه ارسال',
'store.shipping.errorLoad': 'بارگذاری تنظیمات ارسال ناموفق بود.',
'store.shipping.errorSaveStatic': 'ذخیره هزینه ارسال ثابت ناموفق بود.',
'store.shipping.errorDelete': 'حذف گروه هزینه ارسال ناموفق بود.',
'store.shipping.deleteTitle': 'حذف گروه هزینه ارسال',
'store.shipping.deleteMessage':
'از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
'store.shipping.toast.staticSaved': 'هزینه ارسال ثابت ذخیره شد.',
'store.shipping.toast.created': 'گروه هزینه ارسال ایجاد شد.',
'store.shipping.toast.updated': 'گروه هزینه ارسال به‌روزرسانی شد.',
'store.shipping.toast.deleted': 'گروه هزینه ارسال حذف شد.',
'store.shipping.tooltip.edit': 'ویرایش',
'store.shipping.tooltip.remove': 'حذف',
'store.shipping.tooltip.exceptions': 'استثناهای استانی',
'store.shipping.tooltip.categories': 'اختصاص دسته‌بندی',
'store.shipping.row.defaultAmount': 'مبلغ پیش‌فرض',
'store.shipping.groupException.title': 'استثناهای استانی',
'store.shipping.groupException.subtitle': 'نرخ‌های ویژه برای «{name}»',
'store.shipping.groupException.empty': 'هنوز استثنای استانی تعریف نشده.',
'store.shipping.groupException.add': 'افزودن استثنای استان',
'store.shipping.groupException.deleteTitle': 'حذف استثنای استان',
'store.shipping.groupException.deleteMessage':
'هزینه ارسال ویژه «{name}» حذف شود؟',
'store.shipping.groupException.toast.saved': 'استثناهای استانی ذخیره شد.',
'store.shipping.groupException.errorSave': 'ذخیره استثناهای استانی ناموفق بود.',
'store.shipping.groupCategories.title': 'اختصاص دسته‌بندی',
'store.shipping.groupCategories.subtitle': 'دسته‌هایی که از «{name}» استفاده می‌کنند',
'store.shipping.groupCategories.loading': 'در حال بارگذاری دسته‌بندی‌ها…',
'store.shipping.groupCategories.save': 'ذخیره دسته‌بندی‌ها',
'store.shipping.groupCategories.toast.saved': 'دسته‌بندی‌ها اختصاص داده شد.',
'store.shipping.groupCategories.errorLoad': 'بارگذاری دسته‌بندی‌ها ناموفق بود.',
'store.shipping.groupCategories.errorSave': 'ذخیره دسته‌بندی‌ها ناموفق بود.',
'store.shipping.static.title': 'هزینه ارسال ثابت برای همه سفارش‌ها',
'store.shipping.static.desc':
'با فعال‌سازی، همه سفارش‌ها همین مبلغ ثابت را می‌گیرند و گروه‌های هزینه نادیده گرفته می‌شوند. عدد صفر معادل با هزینه ارسال رایگان می‌باشد.',
'store.shipping.static.amount': 'مبلغ (IRT)',
'store.shipping.static.amountPlaceholder': 'مثلاً ۱۵۰,۰۰۰',
'store.shipping.static.exception.title': 'استثناهای استانی',
'store.shipping.static.exception.desc':
'برای استان‌های خاص مبلغ متفاوتی از هزینه ثابت تعریف کنید.',
'store.shipping.static.exception.disabledHint':
'برای مدیریت استثناهای استانی، هزینه ارسال ثابت را فعال کنید.',
'store.shipping.static.exception.empty': 'هنوز استثنای استانی تعریف نشده.',
'store.shipping.static.exception.add': 'افزودن استثنای استان',
'store.shipping.static.exception.addTitle': 'افزودن استثنای استان',
'store.shipping.static.exception.editTitle': 'ویرایش استثنای استان',
'store.shipping.static.exception.province': 'استان',
'store.shipping.static.exception.provincePlaceholder': 'یک استان انتخاب کنید',
'store.shipping.static.exception.amount': 'مبلغ (IRT)',
'store.shipping.static.exception.errorProvince': 'یک استان انتخاب کنید.',
'store.shipping.static.exception.deleteTitle': 'حذف استثنای استان',
'store.shipping.static.exception.deleteMessage':
'هزینه ارسال ویژه «{name}» حذف شود؟',
'store.shipping.groups.title': 'گروه‌های هزینه ارسال',
'store.shipping.groups.desc':
'گروه‌هایی با روش‌های ارسال و مبلغ پیش‌فرض بسازید. بعداً می‌توانید آن‌ها را به دسته‌ها اختصاص دهید.',
'store.shipping.groups.disabledHint':
'برای استفاده از گروه‌های هزینه این گزینه را فعال کنید. هزینه ارسال ثابت غیرفعال می‌شود.',
'store.shipping.form.addTitle': 'افزودن گروه هزینه ارسال',
'store.shipping.form.editTitle': 'ویرایش گروه هزینه ارسال',
'store.shipping.form.nameFa': 'نام فارسی',
'store.shipping.form.nameEn': 'نام انگلیسی',
'store.shipping.form.nameFaPlaceholder': 'مثلاً گروه لوازم خانگی',
'store.shipping.form.nameEnPlaceholder': 'e.g. Home appliances group',
'store.shipping.form.description': 'توضیحات',
'store.shipping.form.descriptionPlaceholder': 'یادداشت برای تیم شما…',
'store.shipping.form.categories': 'دسته‌بندی‌های اختصاص‌یافته',
'store.shipping.form.categoriesHint':
'محصولات این دسته‌ها از این گروه هزینه ارسال استفاده می‌کنند.',
'store.shipping.form.categoriesSearch': 'حداقل ۳ حرف برای جستجوی دسته‌بندی تایپ کنید',
'store.shipping.form.categoriesLoading': 'در حال بارگذاری دسته‌بندی‌ها…',
'store.shipping.form.errorCategoriesLoad': 'بارگذاری دسته‌بندی‌ها ناموفق بود.',
'store.shipping.form.methods': 'روش‌های ارسال فعال',
'store.shipping.form.methodsHint': 'پیک‌هایی که مشتری می‌تواند برای این گروه انتخاب کند.',
'store.shipping.form.defaultAmount': 'مبلغ پیش‌فرض (IRT)',
'store.shipping.form.defaultAmountHint':
'برای همه استان‌ها اعمال می‌شود مگر اینکه برای شهر خاصی مبلغ دیگری تعیین شود.',
'store.shipping.form.defaultAmountPlaceholder': 'مثلاً ۱۲۰,۰۰۰',
'store.shipping.form.isDefault': 'این گروه را به‌عنوان گروه پیش‌فرض تنظیم کن',
'store.shipping.form.isDefaultHint':
'دسته‌هایی که گروه ارسال اختصاصی ندارند از این گروه استفاده می‌کنند.',
'store.shipping.row.defaultBadge': 'پیش‌فرض',
'store.shipping.form.cancel': 'انصراف',
'store.shipping.form.save': 'ذخیره',
'store.shipping.form.update': 'به‌روزرسانی',
'store.shipping.form.saving': 'در حال ذخیره…',
'store.shipping.form.errorRequired': 'لطفاً همه فیلدهای الزامی را پر کنید.',
'store.shipping.form.errorMethods': 'حداقل یک روش ارسال انتخاب کنید.',
'store.shipping.form.errorAmount': 'یک مبلغ معتبر به IRT وارد کنید.',
'store.shipping.form.errorSave': 'ذخیره گروه هزینه ارسال ناموفق بود.',
'store.shipping.method.post': 'پست',
'store.shipping.method.tipax': 'تیپاکس',
'store.shipping.method.mahex': 'ماهکس',
'store.shipping.method.freight': 'باربری',
'store.shipping.method.air': 'ارسال هوایی',
'store.installments.page.subtitle':
'روش‌های وام و اقساطی که فروشگاه به مشتریان ارائه می‌دهد را بسازید.',
'store.installments.loading': 'در حال بارگذاری روش‌های اقساط…',
@@ -3953,6 +4158,12 @@ const fa: Record<MessageKey, string> = {
'orderItems.item': 'قلم',
'orderItems.itemsPlural': 'قلم',
'orderItems.close': 'بستن',
'orderItems.delivery': 'ارسال',
'orderItems.pickup': 'تحویل حضوری',
'orderItems.subtotal': 'جمع جزء',
'orderItems.shipping': 'هزینه ارسال',
'orderItems.discount': 'تخفیف',
'orderItems.freeShipping': 'ارسال رایگان',
'orderTransactions.title': 'جزئیات تراکنش',
'orderTransactions.empty': 'تراکنش پرداختی برای این سفارش ثبت نشده است.',
@@ -5132,6 +5343,7 @@ const fa: Record<MessageKey, string> = {
'title.storeItems': 'محصولات فروشگاه',
'title.orders': 'سفارش‌های من',
'title.shoppingCards': 'کارت‌های خرید',
'title.shipping': 'هزینه‌های ارسال',
'title.installments': 'اقساط و تسهیلات',
'title.addInstallment': 'افزودن روش اقساط',
'title.editInstallment': 'ویرایش روش اقساط',
@@ -5654,6 +5866,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/products', labels: [t('title.products')] },
{ match: '/store/items', labels: [t('title.store'), t('title.storeItems')] },
{ match: '/store/orders', labels: [t('title.store'), t('title.orders')] },
{ match: '/store/shipping', labels: [t('title.store'), t('title.shipping')] },
{ match: '/store/cards', labels: [t('title.store'), t('title.shoppingCards')] },
{
match: '/store/installments/new',
@@ -0,0 +1,326 @@
.methodPanel {
margin-bottom: 0;
}
.methodTopRow {
display: flex;
align-items: center;
gap: 14px;
}
.methodBody {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
transition: opacity 0.2s, filter 0.2s;
}
.methodHeading {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 10px;
}
.methodTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
.methodDesc {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
.methodInactive {
opacity: 0.45;
filter: blur(0.6px) grayscale(0.2);
pointer-events: none;
user-select: none;
}
.amountInline {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
direction: ltr;
}
.amountInput {
width: 140px;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.amountInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.amountInput:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.amountUnit {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.amountSaveBtn {
flex-shrink: 0;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-start: auto;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
}
.amountSaveBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.amountSaveBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.exceptionsBlock {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
transition: opacity 0.2s, filter 0.2s;
}
.exceptionsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.exceptionsHeading {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 10px;
}
.exceptionsTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.exceptionsDesc {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.exceptionAddBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
}
.exceptionAddBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.exceptionAddBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.exceptionsEmpty {
margin: 0;
padding: 12px 0 2px;
font-size: 13px;
color: var(--text-muted);
}
.exceptionsList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-sm);
overflow: hidden;
}
.exceptionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.exceptionRow:last-child {
border-bottom: none;
}
.exceptionProvince {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.exceptionAmount {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.exceptionControls {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.exceptionControlBtn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.exceptionControlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.exceptionControlBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exceptionDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.sectionDivider {
height: 1px;
margin: 28px 0;
background: linear-gradient(
to left,
transparent,
rgba(148, 163, 184, 0.45) 12%,
rgba(148, 163, 184, 0.45) 88%,
transparent
);
}
.groupsContent {
margin-top: 16px;
padding-top: 4px;
transition: opacity 0.2s, filter 0.2s;
}
.groupsList {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
@media (max-width: 900px) {
.methodTopRow {
align-items: flex-start;
}
.methodBody {
flex-wrap: wrap;
}
.amountInline {
width: 100%;
}
.amountInput {
flex: 1;
width: auto;
}
.amountSaveBtn {
margin-inline-start: 0;
}
}
@@ -0,0 +1,701 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Pencil, Plus, Trash2, Truck } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
import { PageTitle } from '../components/PageTitle'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ShippingCostGroupModal } from '../components/ShippingCostGroupModal'
import { ShippingCostGroupRow } from '../components/ShippingCostGroupRow'
import { ShippingGroupCategoriesModal } from '../components/ShippingGroupCategoriesModal'
import { ShippingGroupExceptionsModal } from '../components/ShippingGroupExceptionsModal'
import { StaticShippingExceptionModal } from '../components/StaticShippingExceptionModal'
import { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
updateStoreSettings,
type StaticShippingProvinceException,
type StoreSettings,
} from '../services/settingsService'
import {
createShippingCostGroup,
deleteShippingCostGroup,
getGroupProvinceExceptions,
listAllShippingCostGroups,
updateShippingCostGroup,
} from '../services/shippingCostGroupService'
import {
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import type {
ShippingCostGroup,
ShippingCostGroupFormPayload,
} from '../types/shippingCostGroup'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import panelStyles from './ProductSettingsPage.module.css'
import listStyles from './BrandsPage.module.css'
import styles from './StoreShippingPage.module.css'
function sortShippingCostGroups(items: ShippingCostGroup[]) {
return [...items].sort((a, b) => {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
return b.createdAt.localeCompare(a.createdAt)
})
}
function upsertShippingCostGroup(
items: ShippingCostGroup[],
next: ShippingCostGroup,
) {
const without = items.filter((item) => item.id !== next.id)
const cleared = next.isDefault
? without.map((item) => (item.isDefault ? { ...item, isDefault: false } : item))
: without
return sortShippingCostGroups([...cleared, next])
}
export function StoreShippingPage() {
const t = useT()
const { showToast } = useToast()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [staticEnabled, setStaticEnabled] = useState(true)
const [staticAmount, setStaticAmount] = useState('')
const [exceptions, setExceptions] = useState<StaticShippingProvinceException[]>([])
const [provinces, setProvinces] = useState<CityOption[]>([])
const [groups, setGroups] = useState<ShippingCostGroup[]>([])
const [isLoading, setIsLoading] = useState(true)
const [savingStatic, setSavingStatic] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [modalOpen, setModalOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<ShippingCostGroup | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ShippingCostGroup | null>(null)
const [exceptionModalOpen, setExceptionModalOpen] = useState(false)
const [editingException, setEditingException] =
useState<StaticShippingProvinceException | null>(null)
const [deleteExceptionTarget, setDeleteExceptionTarget] =
useState<StaticShippingProvinceException | null>(null)
const [groupExceptionsTarget, setGroupExceptionsTarget] =
useState<ShippingCostGroup | null>(null)
const [groupCategoriesTarget, setGroupCategoriesTarget] =
useState<ShippingCostGroup | null>(null)
const groupsEnabled = !staticEnabled
const groupsDisabled = !groupsEnabled
const exceptionsDisabled = !staticEnabled
const staticInactive = !staticEnabled
const groupsInactive = !groupsEnabled
const provinceNameById = useMemo(() => {
const map = new Map<string, string>()
for (const province of provinces) {
map.set(
province.id,
isFa
? province.nameFa || province.nameEn
: province.nameEn || province.nameFa,
)
}
return map
}, [provinces, isFa])
useEffect(() => {
const controller = new AbortController()
void loadPage(controller.signal)
return () => controller.abort()
}, [])
async function loadPage(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const [settingsRes, groupItems, provinceItems] = await Promise.all([
getSettings(signal),
listAllShippingCostGroups(signal),
listIranProvinces(signal),
])
const store = settingsRes.settings.store
setStaticEnabled(Boolean(store.staticShippingEnabled))
setStaticAmount(
formatIrtInput(String(Math.round(Number(store.staticShippingAmount ?? 0)))),
)
setExceptions(store.staticShippingProvinceExceptions ?? [])
setProvinces(provinceItems)
setGroups(sortShippingCostGroups(groupItems))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function persistStaticShipping(next: {
enabled: boolean
amount: number
provinceExceptions?: StaticShippingProvinceException[]
}) {
setSavingStatic(true)
setError('')
try {
const patch: Partial<StoreSettings> = {
staticShippingEnabled: next.enabled,
staticShippingAmount: next.amount,
staticShippingProvinceExceptions:
next.provinceExceptions ?? exceptions,
}
const data = await updateStoreSettings(patch)
const store = data.settings.store
setStaticEnabled(Boolean(store.staticShippingEnabled))
setStaticAmount(
formatIrtInput(String(Math.round(Number(store.staticShippingAmount ?? 0)))),
)
setExceptions(store.staticShippingProvinceExceptions ?? [])
showToast(t('store.shipping.toast.staticSaved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.errorSaveStatic'))
}
throw err
} finally {
setSavingStatic(false)
}
}
async function handleStaticToggle(checked: boolean) {
const amount = parseIrtInput(staticAmount) ?? 0
try {
await persistStaticShipping({ enabled: checked, amount })
} catch {
// error already surfaced
}
}
async function handleGroupsToggle(checked: boolean) {
const amount = parseIrtInput(staticAmount) ?? 0
try {
// Groups mode is the inverse of static shipping.
await persistStaticShipping({ enabled: !checked, amount })
} catch {
// error already surfaced
}
}
async function handleStaticAmountSave() {
if (!staticEnabled) return
const amount = parseIrtInput(staticAmount)
if (amount === null) {
setError(t('store.shipping.form.errorAmount'))
return
}
try {
await persistStaticShipping({ enabled: true, amount })
} catch {
// error already surfaced
}
}
function openCreateModal() {
if (groupsDisabled) return
setEditingGroup(null)
setModalOpen(true)
}
function openEditModal(id: string) {
if (groupsDisabled) return
const group = groups.find((item) => item.id === id) ?? null
setEditingGroup(group)
setModalOpen(true)
}
async function handleModalSubmit(payload: ShippingCostGroupFormPayload) {
setIsSubmitting(true)
setError('')
try {
if (editingGroup) {
const res = await updateShippingCostGroup(editingGroup.id, payload)
setGroups((prev) => upsertShippingCostGroup(prev, res.group))
showToast(t('store.shipping.toast.updated'), 'success')
} else {
const res = await createShippingCostGroup(payload)
setGroups((prev) => upsertShippingCostGroup(prev, res.group))
showToast(t('store.shipping.toast.created'), 'success')
}
setModalOpen(false)
setEditingGroup(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
await deleteShippingCostGroup(deleteTarget.id)
setGroups((prev) => prev.filter((item) => item.id !== deleteTarget.id))
setDeleteTarget(null)
showToast(t('store.shipping.toast.deleted'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.errorDelete'))
}
} finally {
setIsSubmitting(false)
}
}
async function handleExceptionSubmit(data: {
provinceId: string
amount: number
}) {
const amount = parseIrtInput(staticAmount) ?? 0
const nextExceptions = editingException
? exceptions.map((item) =>
item.provinceId === editingException.provinceId ? data : item,
)
: [...exceptions.filter((item) => item.provinceId !== data.provinceId), data]
setIsSubmitting(true)
try {
await persistStaticShipping({
enabled: staticEnabled,
amount,
provinceExceptions: nextExceptions,
})
setExceptionModalOpen(false)
setEditingException(null)
} catch {
// error already surfaced
} finally {
setIsSubmitting(false)
}
}
async function confirmDeleteException() {
if (!deleteExceptionTarget) return
const amount = parseIrtInput(staticAmount) ?? 0
const nextExceptions = exceptions.filter(
(item) => item.provinceId !== deleteExceptionTarget.provinceId,
)
setIsSubmitting(true)
try {
await persistStaticShipping({
enabled: staticEnabled,
amount,
provinceExceptions: nextExceptions,
})
setDeleteExceptionTarget(null)
} catch {
// error already surfaced
} finally {
setIsSubmitting(false)
}
}
async function handleGroupExceptionsSave(
items: Array<{ provinceId: string; amount: number }>,
) {
if (!groupExceptionsTarget) return
setIsSubmitting(true)
setError('')
try {
const res = await updateShippingCostGroup(groupExceptionsTarget.id, {
provinceExceptions: items,
})
setGroups((prev) =>
prev.map((item) => (item.id === groupExceptionsTarget.id ? res.group : item)),
)
setGroupExceptionsTarget(res.group)
showToast(t('store.shipping.groupException.toast.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupException.errorSave'))
}
throw err
} finally {
setIsSubmitting(false)
}
}
async function handleGroupCategoriesSave(categoryIds: string[]) {
if (!groupCategoriesTarget) return
setIsSubmitting(true)
setError('')
try {
const res = await updateShippingCostGroup(groupCategoriesTarget.id, {
categoryIds,
})
setGroups((prev) =>
prev.map((item) => (item.id === groupCategoriesTarget.id ? res.group : item)),
)
setGroupCategoriesTarget(res.group)
showToast(t('store.shipping.groupCategories.toast.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupCategories.errorSave'))
}
throw err
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: t('title.shipping') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="SHIPPING FEES">{t('title.shipping')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('store.shipping.page.subtitle')}</p>
</div>
</div>
{error ? (
<div className={listStyles.error} role="alert">
{error}
</div>
) : null}
<section className={`${panelStyles.panel} ${styles.methodPanel}`}>
{isLoading ? (
<p className={panelStyles.status}>{t('store.shipping.loading')}</p>
) : (
<>
<div className={styles.methodTopRow}>
<Switch
id="static-shipping"
checked={staticEnabled}
disabled={savingStatic}
aria-label={t('store.shipping.static.title')}
onChange={(checked) => void handleStaticToggle(checked)}
/>
<div
className={`${styles.methodBody} ${staticInactive ? styles.methodInactive : ''}`}
>
<div className={styles.methodHeading}>
<label htmlFor="static-shipping" className={styles.methodTitle}>
{t('store.shipping.static.title')}
</label>
<p className={styles.methodDesc}>{t('store.shipping.static.desc')}</p>
</div>
<div className={styles.amountInline}>
<label htmlFor="static-shipping-amount" className={styles.srOnly}>
{t('store.shipping.static.amount')}
</label>
<input
id="static-shipping-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
className={styles.amountInput}
placeholder={t('store.shipping.static.amountPlaceholder')}
value={staticAmount}
onChange={(e) => setStaticAmount(formatIrtInput(e.target.value))}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.static.amount')}
/>
<span className={styles.amountUnit}>IRT</span>
</div>
<Tooltip label={t('store.shipping.form.save')}>
<button
type="button"
className={styles.amountSaveBtn}
onClick={() => void handleStaticAmountSave()}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.form.save')}
>
<Check size={18} />
</button>
</Tooltip>
</div>
</div>
<div
className={`${styles.exceptionsBlock} ${staticInactive || exceptionsDisabled ? styles.methodInactive : ''}`}
>
<div className={styles.exceptionsHeader}>
<div className={styles.exceptionsHeading}>
<h4 className={styles.exceptionsTitle}>
{t('store.shipping.static.exception.title')}
</h4>
<p className={styles.exceptionsDesc}>
{staticInactive
? t('store.shipping.static.exception.disabledHint')
: t('store.shipping.static.exception.desc')}
</p>
</div>
<Tooltip label={t('store.shipping.static.exception.add')}>
<button
type="button"
className={styles.exceptionAddBtn}
onClick={() => {
if (staticInactive) return
setEditingException(null)
setExceptionModalOpen(true)
}}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.static.exception.add')}
>
<Plus size={18} />
</button>
</Tooltip>
</div>
{exceptions.length === 0 ? (
<p className={styles.exceptionsEmpty}>
{t('store.shipping.static.exception.empty')}
</p>
) : (
<ul className={styles.exceptionsList}>
{exceptions.map((item) => (
<li key={item.provinceId} className={styles.exceptionRow}>
<span className={styles.exceptionProvince}>
{provinceNameById.get(item.provinceId) ?? item.provinceId}
</span>
<span className={styles.exceptionAmount}>
{formatIrtPrice(item.amount)}
</span>
<div className={styles.exceptionControls}>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.exceptionControlBtn}
onClick={() => {
setEditingException(item)
setExceptionModalOpen(true)
}}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.exceptionControlBtn} ${styles.exceptionDanger}`}
onClick={() => setDeleteExceptionTarget(item)}
disabled={staticInactive || savingStatic}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</section>
<div className={styles.sectionDivider} aria-hidden="true" />
<section className={`${panelStyles.panel} ${styles.methodPanel}`}>
{isLoading ? (
<p className={panelStyles.status}>{t('store.shipping.loading')}</p>
) : (
<>
<div className={styles.methodTopRow}>
<Switch
id="groups-shipping"
checked={groupsEnabled}
disabled={savingStatic}
aria-label={t('store.shipping.groups.title')}
onChange={(checked) => void handleGroupsToggle(checked)}
/>
<div
className={`${styles.methodBody} ${groupsInactive ? styles.methodInactive : ''}`}
>
<div className={styles.methodHeading}>
<label htmlFor="groups-shipping" className={styles.methodTitle}>
{t('store.shipping.groups.title')}
</label>
<p className={styles.methodDesc}>
{groupsInactive
? t('store.shipping.groups.disabledHint')
: t('store.shipping.groups.desc')}
</p>
</div>
<Tooltip label={t('store.shipping.add')}>
<button
type="button"
className={styles.exceptionAddBtn}
onClick={openCreateModal}
disabled={groupsInactive || isSubmitting}
aria-label={t('store.shipping.add')}
>
<Plus size={18} />
</button>
</Tooltip>
</div>
</div>
<div
className={`${styles.groupsContent} ${groupsInactive ? styles.methodInactive : ''}`}
>
{groups.length === 0 ? (
<EmptyState
icon={Truck}
message={t('store.shipping.empty')}
actionLabel={groupsInactive ? undefined : t('store.shipping.add')}
onAction={groupsInactive ? undefined : openCreateModal}
/>
) : (
<div className={styles.groupsList}>
{groups.map((group) => (
<ShippingCostGroupRow
key={group.id}
group={group}
disabled={groupsInactive}
onEdit={openEditModal}
onCategories={(id) => {
const item = groups.find((entry) => entry.id === id)
if (item) setGroupCategoriesTarget(item)
}}
onExceptions={(id) => {
const item = groups.find((entry) => entry.id === id)
if (item) setGroupExceptionsTarget(item)
}}
onRemove={(id) => {
const item = groups.find((entry) => entry.id === id)
if (item) setDeleteTarget(item)
}}
/>
))}
</div>
)}
</div>
</>
)}
</section>
<ShippingCostGroupModal
open={modalOpen}
editingGroup={editingGroup}
isSubmitting={isSubmitting}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingGroup(null)
}
}}
onSubmit={(payload) => void handleModalSubmit(payload)}
/>
<ShippingGroupExceptionsModal
open={!!groupExceptionsTarget}
groupName={
groupExceptionsTarget
? groupExceptionsTarget.nameFa || groupExceptionsTarget.nameEn
: ''
}
provinces={provinces}
exceptions={
groupExceptionsTarget
? getGroupProvinceExceptions(groupExceptionsTarget)
: []
}
isSubmitting={isSubmitting}
onClose={() => !isSubmitting && setGroupExceptionsTarget(null)}
onSave={handleGroupExceptionsSave}
/>
<ShippingGroupCategoriesModal
open={!!groupCategoriesTarget}
groupName={
groupCategoriesTarget
? groupCategoriesTarget.nameFa || groupCategoriesTarget.nameEn
: ''
}
selectedCategoryIds={groupCategoriesTarget?.categoryIds ?? []}
isSubmitting={isSubmitting}
onClose={() => !isSubmitting && setGroupCategoriesTarget(null)}
onSave={handleGroupCategoriesSave}
/>
<StaticShippingExceptionModal
open={exceptionModalOpen}
provinces={provinces}
usedProvinceIds={exceptions.map((item) => item.provinceId)}
editing={editingException}
isSubmitting={isSubmitting || savingStatic}
onClose={() => {
if (!isSubmitting && !savingStatic) {
setExceptionModalOpen(false)
setEditingException(null)
}
}}
onSubmit={(data) => void handleExceptionSubmit(data)}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('store.shipping.deleteTitle')}
message={
deleteTarget
? t('store.shipping.deleteMessage', {
name: deleteTarget.nameFa || deleteTarget.nameEn,
})
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<ConfirmDeleteModal
open={!!deleteExceptionTarget}
title={t('store.shipping.static.exception.deleteTitle')}
message={
deleteExceptionTarget
? t('store.shipping.static.exception.deleteMessage', {
name:
provinceNameById.get(deleteExceptionTarget.provinceId) ??
deleteExceptionTarget.provinceId,
})
: ''
}
onConfirm={confirmDeleteException}
onCancel={() => !isSubmitting && setDeleteExceptionTarget(null)}
/>
</main>
)
}
@@ -62,6 +62,14 @@ export interface StoreSettings {
torobEnabled: boolean
orderProcessSteps: OrderProcessStep[]
ePayment: EPaymentSettings
staticShippingEnabled?: boolean
staticShippingAmount?: number
staticShippingProvinceExceptions?: StaticShippingProvinceException[]
}
export interface StaticShippingProvinceException {
provinceId: string
amount: number
}
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
@@ -0,0 +1,92 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
ShippingCostGroup,
ShippingCostGroupFormPayload,
ShippingCostGroupsListResponse,
} from '../types/shippingCostGroup'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/shipping-cost-groups${suffix}`
}
export async function listShippingCostGroups(
page = 1,
pageSize = 100,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
return apiRequest<ShippingCostGroupsListResponse>(
`${businessPath()}?${params.toString()}`,
{ auth: true, signal },
)
}
export async function listAllShippingCostGroups(signal?: AbortSignal) {
const pageSize = 100
let page = 1
let items: ShippingCostGroup[] = []
let total = Infinity
while (items.length < total) {
const data = await listShippingCostGroups(page, pageSize, signal)
items = items.concat(data.items)
total = data.total
page += 1
}
return items
}
export async function getShippingCostGroup(groupId: string, signal?: AbortSignal) {
return apiRequest<{ group: ShippingCostGroup }>(businessPath(`/${groupId}`), {
auth: true,
signal,
})
}
export async function createShippingCostGroup(payload: ShippingCostGroupFormPayload) {
return apiRequest<{ message: string; group: ShippingCostGroup }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateShippingCostGroup(
groupId: string,
payload: Partial<ShippingCostGroupFormPayload>,
) {
return apiRequest<{ message: string; group: ShippingCostGroup }>(
businessPath(`/${groupId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export function getGroupProvinceExceptions(group: ShippingCostGroup) {
if (group.provinceExceptions?.length) return group.provinceExceptions
return (group.cityRates ?? []).map((rate) => ({
id: rate.id,
provinceId: rate.cityId,
amount: rate.amount,
}))
}
export async function deleteShippingCostGroup(groupId: string) {
return apiRequest<{ message: string }>(businessPath(`/${groupId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,67 @@
export const SHIPPING_METHOD_IDS = [
'post',
'tipax',
'mahex',
'freight',
'air',
] as const
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number]
export interface ShippingCostGroupCategory {
id: string
name: string
nameFa: string | null
slug: string
}
export interface ShippingCostGroupCityRate {
id: string
cityId: string
amount: number
}
export interface ShippingCostGroupProvinceException {
id: string
provinceId: string
amount: number
}
export interface ShippingCostGroup {
id: string
businessId: string
nameFa: string
nameEn: string
description: string | null
methods: ShippingMethodId[]
defaultAmount: number
sortOrder: number
isActive: boolean
isDefault: boolean
categoryIds: string[]
categories: ShippingCostGroupCategory[]
cityRates: ShippingCostGroupCityRate[]
provinceExceptions?: ShippingCostGroupProvinceException[]
createdAt: string
updatedAt: string
}
export interface ShippingCostGroupFormPayload {
nameFa: string
nameEn: string
description?: string
methods: ShippingMethodId[]
defaultAmount: number
isDefault?: boolean
categoryIds?: string[]
provinceExceptions?: Array<{ provinceId: string; amount: number }>
sortOrder?: number
isActive?: boolean
}
export interface ShippingCostGroupsListResponse {
items: ShippingCostGroup[]
total: number
page: number
pageSize: number
}
@@ -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>
)
}
+149 -9
View File
@@ -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>
+12
View File
@@ -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': 'محصولاتی که برای بعد ذخیره کرده‌اید.',
+15 -3
View File
@@ -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;
+12
View File
@@ -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
}