Files
dashboards/apps/business/src/components/CategoryTreeMultiSelect.tsx
T
Alireza HassaniandCursor c59ca4444f 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>
2026-10-02 21:52:47 +03:30

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>
)
}