Add shipping cost management and checkout shipping fees.
Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
95390933a7
commit
c59ca4444f
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user