Unify category tree pickers and prefer free-text technical AI fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 20:02:05 +03:30
co-authored by Cursor
parent 838e84056e
commit 8465a29ce1
40 changed files with 1192 additions and 586 deletions
@@ -1,9 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { useMemo } from 'react'
import type { UserProductCategoryOption } from '../services/userProductsService'
import styles from './CategorySearchSelect.module.css'
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
dropdownVariant?: 'overlay' | 'inFlow'
}
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
if (isFa) {
return category.nameFa?.trim() || category.name
}
return category.name || category.nameFa?.trim() || ''
}
/** @deprecated Prefer CategoryTreeSelect; kept for callers that flatten options. */
export function flattenCategoryTree(
categories: UserProductCategoryOption[],
): FlatCategoryOption[] {
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
}
for (const list of byParent.values()) {
list.sort((a, b) => {
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
return aLabel
})
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
}
const result: FlatCategoryOption[] = []
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? []
for (const child of children) {
if (!ids.has(child.id)) continue
result.push({ ...child, depth })
walk(child.id, depth + 1)
}
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
walk(null, 0)
const rooted = new Set(result.map((item) => item.id))
for (const category of categories) {
if (category.parentId && !ids.has(category.parentId)) {
if (!result.some((item) => item.id === category.id)) {
result.push({ ...category, depth: 0 })
walk(category.id, 1)
}
if (!rooted.has(category.id)) {
result.push({ ...category, depth: 0 })
}
}
return result
}
function filterCategoryTree(
flat: FlatCategoryOption[],
query: string,
): FlatCategoryOption[] {
const q = query.trim().toLowerCase()
if (!q) return flat
const byId = new Map(flat.map((item) => [item.id, item]))
const matchedIds = new Set<string>()
for (const item of flat) {
const nameEn = item.name.toLowerCase()
const nameFa = (item.nameFa || '').toLowerCase()
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
matchedIds.add(item.id)
let parentId = item.parentId
while (parentId) {
matchedIds.add(parentId)
parentId = byId.get(parentId)?.parentId ?? null
}
}
}
return flat.filter((item) => matchedIds.has(item.id))
}
function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean {
const current = items[index]
if (current.depth === 0) return false
const next = items[index + 1]
return !next || next.depth < current.depth
}
function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
if (depth === 0) return null
return (
<div className={styles.treeGutters} aria-hidden>
{Array.from({ length: depth }, (_, level) => {
const isBranchLevel = level === depth - 1
return (
<span
key={level}
className={[
styles.treeGutter,
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
]
.filter(Boolean)
.join(' ')}
/>
)
})}
</div>
)
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
return options.map((option) => ({
id: option.id,
parentId: option.parentId,
nameEn: option.name,
nameFa: option.nameFa?.trim() || '',
}))
}
/** Marketplace categories — same collapsible tree UX as business dashboards. */
export function CategorySearchSelect({
options,
value,
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
allowEmpty = false,
dropdownVariant = 'overlay',
}: CategorySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const flat = useMemo(() => flattenCategoryTree(options), [options])
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
const selected = flat.find((option) => option.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCategory')
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])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
const dropdownClassName = [
styles.dropdown,
dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
]
.filter(Boolean)
.join(' ')
const categories = useMemo(() => toTreeItems(options), [options])
return (
<div
className={styles.wrapper}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('myProducts.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={dropdownClassName} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('myProducts.fields.noCategories')}</li>
) : (
filtered.map((option, index) => {
const isLastChild = isLastChildInGroup(filtered, index)
return (
<li
key={option.id}
className={[
styles.optionItem,
isLastChild ? styles.optionItemGroupEnd : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={[
styles.option,
option.depth === 0 ? styles.optionParent : styles.optionChild,
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{option.depth > 0 ? (
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
) : null}
<span className={styles.optionBody}>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</span>
</button>
</li>
)
})
)}
</ul>
) : null}
</div>
<CategoryTreeSelect
id={id}
categories={categories}
value={value}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
allowEmpty={allowEmpty}
dropdownVariant={dropdownVariant}
/>
)
}
@@ -0,0 +1,18 @@
.childrenWrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease;
}
.childrenWrap.expanded {
grid-template-rows: 1fr;
}
.childrenInner {
overflow: hidden;
margin-inline-start: 28px;
margin-bottom: 10px;
padding-inline-start: 22px;
padding-inline-end: 8px;
border-inline-start: 1px solid rgba(148, 163, 184, 0.55);
}
@@ -0,0 +1,218 @@
.wrapper {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.wrapperOpen {
z-index: 130;
}
.inputWrap {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--field-height);
padding: 0 var(--field-padding-x);
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;
box-sizing: border-box;
}
.inputWrapOpen,
.inputWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.input::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.clearBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.clearBtn:hover {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.fieldChevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.fieldChevronOpen {
transform: rotate(180deg);
}
.panel {
max-height: min(320px, 50vh);
overflow-y: auto;
padding: 4px;
background: var(--elevated-surface);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
}
.panelOverlay {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
}
.panelInFlow {
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
z-index: auto;
}
.noneOption {
width: 100%;
padding: 8px 10px;
margin-bottom: 4px;
border-radius: 8px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.noneOption:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--text-primary);
}
.noneOptionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 6px 8px;
border-radius: 8px;
text-align: start;
color: var(--text-primary);
transition: background 0.15s;
}
.row:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.rowSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
}
.rowSelected:hover {
background: rgba(var(--primary-rgb) / 0.16);
}
.toggleBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--text-muted);
background: transparent;
cursor: pointer;
outline: none;
transition: background 0.15s, color 0.15s;
}
.toggleBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.toggleSpacer {
flex-shrink: 0;
width: 24px;
height: 24px;
}
.selectBtn {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
padding: 2px 0;
text-align: start;
color: inherit;
font-size: var(--field-font-size);
font-family: var(--font-ui);
background: transparent;
cursor: pointer;
outline: none;
}
.name {
font-weight: 500;
}
.nameSecondary {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.rowSelected .nameSecondary {
color: inherit;
opacity: 0.75;
}
.noResults {
padding: 16px 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,357 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import treeStyles from './CategoryTree.module.css'
import styles from './CategoryTreeSelect.module.css'
/** Minimal category shape for the shared tree picker. */
export interface CategoryTreeItem {
id: string
parentId: string | null
nameEn: string
nameFa: string
}
interface CategoryTreeSelectProps {
categories: CategoryTreeItem[]
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
id?: string
/** Show empty option + clear control. */
allowEmpty?: boolean
/** Label for the empty option when `allowEmpty` is true. */
emptyLabel?: string
/** Overlay floats over content; inFlow expands the parent. */
dropdownVariant?: 'overlay' | 'inFlow'
}
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 categoryLabel(category: CategoryTreeItem, isFa: boolean) {
if (isFa) {
return category.nameFa
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
: category.nameEn
}
return category.nameEn
? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}`
: category.nameFa
}
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 TreeNodes({
categories,
parentId,
value,
expandedIds,
visibleIds,
forceExpanded,
onToggle,
onSelect,
}: {
categories: CategoryTreeItem[]
parentId: string | null
value: string
expandedIds: Set<string>
visibleIds: Set<string> | null
forceExpanded: boolean
onToggle: (id: string) => void
onSelect: (id: string) => void
}) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
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 = value === category.id
const primary = isFa ? category.nameFa || category.nameEn : category.nameEn
const secondary = isFa
? category.nameFa && category.nameEn && category.nameFa !== category.nameEn
? category.nameEn
: ''
: category.nameFa
return (
<div key={category.id}>
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={styles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggle(category.id)}
>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
<span className={styles.toggleSpacer} />
)}
<button
type="button"
className={styles.selectBtn}
onClick={() => onSelect(category.id)}
>
<span
className={styles.name}
dir={isFa ? 'rtl' : 'ltr'}
lang={isFa ? 'fa' : 'en'}
>
{primary}
</span>
{secondary ? (
<span
className={styles.nameSecondary}
dir={isFa ? 'ltr' : 'rtl'}
lang={isFa ? 'en' : 'fa'}
>
· {secondary}
</span>
) : null}
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
onToggle={onToggle}
onSelect={onSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
export function CategoryTreeSelect({
categories,
value,
onChange,
placeholder,
disabled = false,
id,
allowEmpty = false,
emptyLabel,
dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const containerRef = useRef<HTMLDivElement>(null)
const selected = categories.find((category) => category.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('categories.select.search')
const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query),
[categories, query],
)
useEffect(() => {
setOpen(false)
setQuery('')
setExpandedIds(new Set())
}, [categories])
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])
useEffect(() => {
if (!value) return
setExpandedIds((prev) => {
const next = new Set(prev)
for (const ancestorId of getAncestorIds(categories, value)) {
next.add(ancestorId)
}
return next
})
}, [categories, value])
function handleToggle(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
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={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onClick={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
]
.filter(Boolean)
.join(' ')}
>
{allowEmpty ? (
<button
type="button"
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
onClick={() => selectOption('')}
>
{emptyOptionLabel}
</button>
) : null}
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
onToggle={handleToggle}
onSelect={selectOption}
/>
)}
</div>
) : null}
</div>
)
}
+14
View File
@@ -396,6 +396,13 @@ const en = {
'otp.signingIn': 'Signing in...',
'otp.error.signIn': 'Unable to sign in with SMS code.',
'categories.tooltip.expand': 'Expand',
'categories.tooltip.collapse': 'Collapse',
'categories.select.search': 'Search categories…',
'categories.select.none': '— No category —',
'categories.select.empty': 'No categories found',
'categories.select.clear': 'Clear selection',
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
@@ -800,6 +807,13 @@ const fa: Record<MessageKey, string> = {
'otp.signingIn': 'در حال ورود...',
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
'categories.tooltip.expand': 'باز کردن',
'categories.tooltip.collapse': 'بستن',
'categories.select.search': 'جستجوی دسته‌بندی‌ها…',
'categories.select.none': '— بدون دسته‌بندی —',
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
'categories.select.clear': 'پاک کردن انتخاب',
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',