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>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|