Add customer and business user-product flows across dashboards.
Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -0,0 +1,246 @@
|
||||
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 type { UserProductCategoryOption } from '../services/userProductsService'
|
||||
import styles from './CategorySearchSelect.module.css'
|
||||
|
||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface CategorySearchSelectProps {
|
||||
options: UserProductCategoryOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
||||
if (isFa) {
|
||||
return category.nameFa?.trim() || category.name
|
||||
}
|
||||
return category.name || category.nameFa?.trim() || ''
|
||||
}
|
||||
|
||||
export function flattenCategoryTree(
|
||||
categories: UserProductCategoryOption[],
|
||||
): FlatCategoryOption[] {
|
||||
const byParent = new Map<string | null, UserProductCategoryOption[]>()
|
||||
|
||||
for (const category of categories) {
|
||||
const parentKey = category.parentId
|
||||
const list = byParent.get(parentKey) ?? []
|
||||
list.push(category)
|
||||
byParent.set(parentKey, list)
|
||||
}
|
||||
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
||||
return aLabel
|
||||
})
|
||||
}
|
||||
|
||||
const result: FlatCategoryOption[] = []
|
||||
const ids = new Set(categories.map((item) => item.id))
|
||||
|
||||
function walk(parentId: string | null, depth: number) {
|
||||
const children = byParent.get(parentId) ?? []
|
||||
for (const child of children) {
|
||||
result.push({ ...child, depth })
|
||||
walk(child.id, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
|
||||
// Orphans whose parent is missing from the list
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
export function CategorySearchSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
id,
|
||||
}: 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('')
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
{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={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('myProducts.fields.noCategories')}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.option,
|
||||
option.depth > 0 ? styles.optionChild : '',
|
||||
value === option.id ? styles.optionSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
option.depth > 0
|
||||
? {
|
||||
marginInlineStart: `${option.depth * 22}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={() => selectOption(option.id)}
|
||||
role="option"
|
||||
aria-selected={value === option.id}
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user