From c59ca4444ffee144106dedab0dcf37e0d40111c4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 2 Oct 2026 21:52:47 +0330 Subject: [PATCH] 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 --- apps/business/src/App.tsx | 2 + .../CategoryTreeMultiSelect.module.css | 134 ++++ .../components/CategoryTreeMultiSelect.tsx | 341 +++++++++ .../src/components/OrderItemsModal.module.css | 107 +++ .../src/components/OrderItemsModal.tsx | 127 +++- .../ShippingCostGroupModal.module.css | 331 +++++++++ .../src/components/ShippingCostGroupModal.tsx | 294 ++++++++ .../ShippingCostGroupRow.module.css | 264 +++++++ .../src/components/ShippingCostGroupRow.tsx | 127 ++++ .../ShippingGroupCategoriesModal.tsx | 210 ++++++ .../ShippingGroupExceptionsModal.module.css | 200 +++++ .../ShippingGroupExceptionsModal.tsx | 265 +++++++ .../StaticShippingExceptionModal.module.css | 181 +++++ .../StaticShippingExceptionModal.tsx | 196 +++++ apps/business/src/i18n/messages.ts | 213 ++++++ .../src/pages/StoreShippingPage.module.css | 326 ++++++++ apps/business/src/pages/StoreShippingPage.tsx | 701 ++++++++++++++++++ apps/business/src/services/settingsService.ts | 8 + .../src/services/shippingCostGroupService.ts | 92 +++ apps/business/src/types/shippingCostGroup.ts | 67 ++ .../src/components/OrderItemsModal.module.css | 107 +++ .../src/components/OrderItemsModal.tsx | 127 +++- .../checkout/CheckoutAddAddressModal.tsx | 6 +- .../checkout/CheckoutAddAddressPanel.tsx | 6 +- .../checkout/CheckoutLayout.module.css | 26 +- .../components/checkout/CheckoutLayout.tsx | 4 +- .../checkout/CheckoutStepper.module.css | 89 +++ .../components/checkout/CheckoutStepper.tsx | 46 +- apps/customer/src/context/CheckoutContext.tsx | 158 +++- apps/customer/src/i18n/messages.ts | 12 + apps/customer/src/pages/LoginPage.tsx | 18 +- .../src/pages/checkout/CheckoutCartStep.tsx | 2 +- .../pages/checkout/CheckoutDeliveryStep.tsx | 338 ++++++++- .../src/pages/checkout/CheckoutFlow.tsx | 54 +- .../pages/checkout/CheckoutPaymentStep.tsx | 41 +- .../pages/checkout/CheckoutSteps.module.css | 150 +++- apps/customer/src/services/cartService.ts | 12 + .../src/services/shippingCostService.ts | 69 ++ 38 files changed, 5353 insertions(+), 98 deletions(-) create mode 100644 apps/business/src/components/CategoryTreeMultiSelect.module.css create mode 100644 apps/business/src/components/CategoryTreeMultiSelect.tsx create mode 100644 apps/business/src/components/ShippingCostGroupModal.module.css create mode 100644 apps/business/src/components/ShippingCostGroupModal.tsx create mode 100644 apps/business/src/components/ShippingCostGroupRow.module.css create mode 100644 apps/business/src/components/ShippingCostGroupRow.tsx create mode 100644 apps/business/src/components/ShippingGroupCategoriesModal.tsx create mode 100644 apps/business/src/components/ShippingGroupExceptionsModal.module.css create mode 100644 apps/business/src/components/ShippingGroupExceptionsModal.tsx create mode 100644 apps/business/src/components/StaticShippingExceptionModal.module.css create mode 100644 apps/business/src/components/StaticShippingExceptionModal.tsx create mode 100644 apps/business/src/pages/StoreShippingPage.module.css create mode 100644 apps/business/src/pages/StoreShippingPage.tsx create mode 100644 apps/business/src/services/shippingCostGroupService.ts create mode 100644 apps/business/src/types/shippingCostGroup.ts create mode 100644 apps/customer/src/services/shippingCostService.ts diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 8fb4487..302a801 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/CategoryTreeMultiSelect.module.css b/apps/business/src/components/CategoryTreeMultiSelect.module.css new file mode 100644 index 0000000..cc97e36 --- /dev/null +++ b/apps/business/src/components/CategoryTreeMultiSelect.module.css @@ -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; +} diff --git a/apps/business/src/components/CategoryTreeMultiSelect.tsx b/apps/business/src/components/CategoryTreeMultiSelect.tsx new file mode 100644 index 0000000..7b27cea --- /dev/null +++ b/apps/business/src/components/CategoryTreeMultiSelect.tsx @@ -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 | null { + const q = query.trim() + if (!q) return null + + const matched = categories.filter((category) => categoryMatches(category, q)) + const visible = new Set() + + 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 ( + + {nameEn ? ( + + {nameEn} + + ) : null} + {nameEn && nameFa ? · : null} + {nameFa ? ( + + {nameFa} + + ) : null} + + ) +} + +function TreeNodes({ + categories, + parentId, + selectedIds, + expandedIds, + visibleIds, + forceExpanded, + disabled, + onToggleExpand, + onToggleSelect, +}: { + categories: CategoryTreeItem[] + parentId: string | null + selectedIds: Set + expandedIds: Set + visibleIds: Set | 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 ( +
+
+ {childCount ? ( + + + + ) : ( + + )} + +
+ {childCount ? ( +
+
+ +
+
+ ) : null} +
+ ) + })} + + ) +} + +export function CategoryTreeMultiSelect({ + categories, + selectedIds, + onToggle, + placeholder, + disabled = false, + id, +}: CategoryTreeMultiSelectProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const containerRef = useRef(null) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [expandedIds, setExpandedIds] = useState>(() => 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 ( +
+
+ + { + 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 ? ( + + ) : null} +
+ + {open && !disabled ? ( +
+ {!hasVisible ? ( +

{t('categories.select.empty')}

+ ) : ( + + )} +
+ ) : null} + + {selectedCategories.length > 0 ? ( +
+ {selectedCategories.map((category) => ( +
+ + +
+ ))} +
+ ) : null} +
+ ) +} diff --git a/apps/business/src/components/OrderItemsModal.module.css b/apps/business/src/components/OrderItemsModal.module.css index 6d288b7..a6c896b 100644 --- a/apps/business/src/components/OrderItemsModal.module.css +++ b/apps/business/src/components/OrderItemsModal.module.css @@ -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); diff --git a/apps/business/src/components/OrderItemsModal.tsx b/apps/business/src/components/OrderItemsModal.tsx index 229f8ff..5c7dfc5 100644 --- a/apps/business/src/components/OrderItemsModal.tsx +++ b/apps/business/src/components/OrderItemsModal.tsx @@ -30,6 +30,54 @@ function totalQuantity(order: Order) { return order.items.reduce((sum, item) => sum + item.quantity, 0) } +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function parseOrderShipping(shippingAddress: Record) { + 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 => 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) + {(() => { + const shippingInfo = parseOrderShipping(order.shippingAddress) + return ( +
+
{t('orderItems.delivery')}
+ {shippingInfo.deliveryMode === 'pickup' ? ( +

{t('orderItems.pickup')}

+ ) : shippingInfo.addressLine ? ( +

{shippingInfo.addressLine}

+ ) : null} + + {shippingInfo.groups.length > 0 ? ( +
    + {shippingInfo.groups.map((group) => ( +
  • +
    + {group.title} + + {group.amount > 0 + ? formatIrtPrice(group.amount) + : t('orderItems.freeShipping')} + +
    + {group.methodLabel ? ( +
    {group.methodLabel}
    + ) : null} + {group.itemNames.length > 0 ? ( +
      + {group.itemNames.map((name) => ( +
    • {name}
    • + ))} +
    + ) : null} +
  • + ))} +
+ ) : null} +
+ ) + })()} + {order.items.length === 0 ? (

{t('orderItems.empty')}

) : ( @@ -144,15 +233,35 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) )} -
- - {t('orderItems.summary', { - count: itemCount, - itemsLabel: - itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'), - })} - - {formatIrtPrice(order.total)} +
+
+ {t('orderItems.subtotal')} + {formatIrtPrice(order.subtotal)} +
+
+ {t('orderItems.shipping')} + + {order.shippingTotal > 0 + ? formatIrtPrice(order.shippingTotal) + : t('orderItems.freeShipping')} + +
+ {order.discountTotal > 0 ? ( +
+ {t('orderItems.discount')} + {formatIrtPrice(order.discountTotal)} +
+ ) : null} +
+ + {t('orderItems.summary', { + count: itemCount, + itemsLabel: + itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'), + })} + + {formatIrtPrice(order.total)} +
diff --git a/apps/business/src/components/ShippingCostGroupModal.module.css b/apps/business/src/components/ShippingCostGroupModal.module.css new file mode 100644 index 0000000..11e2e12 --- /dev/null +++ b/apps/business/src/components/ShippingCostGroupModal.module.css @@ -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); + } +} diff --git a/apps/business/src/components/ShippingCostGroupModal.tsx b/apps/business/src/components/ShippingCostGroupModal.tsx new file mode 100644 index 0000000..694fd7a --- /dev/null +++ b/apps/business/src/components/ShippingCostGroupModal.tsx @@ -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 = { + 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([]) + 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) { + 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( +
!isSubmitting && onClose()} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="shipping-group-modal-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} + > +
+

+ {editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')} +

+ +
+ +
+ {formError ? ( +
+ {formError} +
+ ) : null} + +
+ + setNameFa(e.target.value)} + disabled={isSubmitting} + required + /> +
+ +
+ + setNameEn(e.target.value)} + disabled={isSubmitting} + required + /> +
+ +
+ +