void
placeholder?: string
+ /** Show "none" option and clear button (e.g. optional brand or category parent picker). */
allowEmpty?: boolean
+ /** Overlay floats over content; inFlow expands the parent (use inside modals). */
+ dropdownVariant?: 'overlay' | 'inFlow'
}
-function isLastChildBeforeParent
(
- items: T[],
- index: number,
-): boolean {
+function isLastChildInGroup(items: T[], index: number): boolean {
const current = items[index]
if (current.depth === 0) return false
const next = items[index + 1]
@@ -34,12 +34,37 @@ function categoryLabel(cat: FlatCategory, isFa: boolean) {
: cat.nameFa
}
+function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
+ if (depth === 0) return null
+
+ return (
+
+ {Array.from({ length: depth }, (_, level) => {
+ const isBranchLevel = level === depth - 1
+ return (
+
+ )
+ })}
+
+ )
+}
+
export function SearchableSelect({
options,
value,
onChange,
placeholder,
allowEmpty = true,
+ dropdownVariant = 'overlay',
}: SearchableSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -84,6 +109,13 @@ export function SearchableSelect({
setOpen(true)
}
+ const dropdownClassName = [
+ styles.dropdown,
+ dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
+ ]
+ .filter(Boolean)
+ .join(' ')
+
return (
@@ -101,7 +133,7 @@ export function SearchableSelect({
onFocus={handleFocus}
dir={isFa ? 'rtl' : 'ltr'}
/>
- {value && !open && (
+ {allowEmpty && value && !open ? (
- )}
+ ) : null}
- {open && (
-
+ {open ? (
+
{allowEmpty ? (
- -
+
-
) : (
- filtered.map((cat, index) => (
- -
-
+
+ )
+ })
)}
- )}
+ ) : null}
)
}
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts
index 7241d56..d446092 100644
--- a/apps/business/src/i18n/messages.ts
+++ b/apps/business/src/i18n/messages.ts
@@ -552,6 +552,7 @@ const en = {
'products.details.ratingAria': 'Rating {rate} out of 10',
'products.form.errorOptions': 'Unable to load form options.',
+ 'products.form.errorCategory': 'Please select a category.',
'products.form.errorSave': 'Unable to save product.',
'products.form.thumbnail': 'Thumbnail Image',
'products.form.thumbnailUpload': 'Upload thumbnail image',
@@ -632,7 +633,7 @@ const en = {
'categories.modal.save': 'Save Category',
'categories.modal.saving': 'Saving...',
'categories.select.search': 'Search categories...',
- 'categories.select.none': '— None (root category) —',
+ 'categories.select.none': '— No parent (top-level category) —',
'categories.select.empty': 'No categories found',
'categories.select.clear': 'Clear selection',
@@ -2863,6 +2864,7 @@ const fa: Record = {
'products.details.ratingAria': 'امتیاز {rate} از ۱۰',
'products.form.errorOptions': 'بارگذاری گزینههای فرم ممکن نشد.',
+ 'products.form.errorCategory': 'لطفاً دستهبندی را انتخاب کنید.',
'products.form.errorSave': 'ذخیره محصول ممکن نشد.',
'products.form.thumbnail': 'تصویر بندانگشتی',
'products.form.thumbnailUpload': 'آپلود تصویر بندانگشتی',
@@ -2943,7 +2945,7 @@ const fa: Record = {
'categories.modal.save': 'ذخیره دستهبندی',
'categories.modal.saving': 'در حال ذخیره...',
'categories.select.search': 'جستجوی دستهبندیها...',
- 'categories.select.none': '— بدون والد (دسته اصلی) —',
+ 'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —',
'categories.select.empty': 'دستهبندیای یافت نشد',
'categories.select.clear': 'پاک کردن انتخاب',
diff --git a/apps/business/src/pages/AddNewProductPage.tsx b/apps/business/src/pages/AddNewProductPage.tsx
index 4e8f270..ff164ee 100644
--- a/apps/business/src/pages/AddNewProductPage.tsx
+++ b/apps/business/src/pages/AddNewProductPage.tsx
@@ -191,6 +191,12 @@ export function AddNewProductPage() {
setIsSubmitting(true)
setError('')
+ if (!categoryId) {
+ setError(t('products.form.errorCategory'))
+ setIsSubmitting(false)
+ return
+ }
+
try {
const featuredMediaId = await resolveDataUrlToMediaId(
thumbnail,
@@ -292,6 +298,7 @@ export function AddNewProductPage() {
value={categoryId}
onChange={setCategoryId}
placeholder={t('products.form.categoryPlaceholder')}
+ allowEmpty={false}
/>
diff --git a/apps/customer/src/components/CategorySearchSelect.module.css b/apps/customer/src/components/CategorySearchSelect.module.css
index 6845fc7..ae4cd6a 100644
--- a/apps/customer/src/components/CategorySearchSelect.module.css
+++ b/apps/customer/src/components/CategorySearchSelect.module.css
@@ -81,27 +81,95 @@
}
.dropdown {
- position: static;
- margin-top: 6px;
- max-height: none;
- overflow: visible;
+ list-style: none;
+ margin: 0;
+ padding: 6px;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
- list-style: none;
- padding: 6px;
- margin: 0;
+}
+
+.dropdownOverlay {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ z-index: 120;
+ max-height: min(320px, 50vh);
+ overflow-y: auto;
+}
+
+.dropdownInFlow {
+ position: static;
+ margin-top: 6px;
+ max-height: none;
+ overflow: visible;
+ z-index: auto;
+}
+
+.optionItem {
+ min-width: 0;
+}
+
+.optionItemGroupEnd {
+ margin-bottom: 10px;
+}
+
+.treeGutters {
+ display: flex;
+ flex-shrink: 0;
+ align-self: stretch;
+}
+
+.treeGutter {
+ position: relative;
+ width: 28px;
+ flex-shrink: 0;
+}
+
+.treeGutterContinue::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ inset-inline-start: 0;
+ border-inline-start: 1px solid rgba(148, 163, 184, 0.55);
+}
+
+.treeGutterBranch::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ inset-inline-start: 0;
+ border-inline-start: 1px solid rgba(148, 163, 184, 0.55);
+}
+
+.treeGutterBranch::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ inset-inline-start: 0;
+ width: 22px;
+ height: 1px;
+ background: rgba(148, 163, 184, 0.55);
+ transform: translateY(-50%);
+}
+
+.treeGutterBranchLast::before {
+ bottom: 50%;
}
.option {
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 6px;
width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
padding: 8px 10px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
@@ -113,34 +181,35 @@
}
.optionChild {
- border-inline-end: 2px solid rgba(var(--primary-rgb) / 0.38);
- border-start-end-radius: 0;
- border-end-end-radius: 0;
- background: rgba(var(--primary-rgb) / 0.03);
- padding-inline-end: 14px;
+ padding-inline-end: 4px;
}
-.optionChildGroupEnd {
- margin-bottom: 10px;
+.optionBody {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: flex-start;
+}
+
+.optionParent {
+ font-weight: 600;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
-.optionChild:hover {
- background: rgba(var(--primary-rgb) / 0.1);
-}
-
.optionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
-.optionChild.optionSelected {
- border-inline-end-color: var(--primary);
- background: rgba(var(--primary-rgb) / 0.14);
+.optionParent.optionSelected {
+ font-weight: 600;
}
.optionLabel {
diff --git a/apps/customer/src/components/CategorySearchSelect.tsx b/apps/customer/src/components/CategorySearchSelect.tsx
index 1853c17..c9b9433 100644
--- a/apps/customer/src/components/CategorySearchSelect.tsx
+++ b/apps/customer/src/components/CategorySearchSelect.tsx
@@ -16,6 +16,8 @@ interface CategorySearchSelectProps {
disabled?: boolean
placeholder?: string
id?: string
+ allowEmpty?: boolean
+ dropdownVariant?: 'overlay' | 'inFlow'
}
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
@@ -57,7 +59,6 @@ export function flattenCategoryTree(
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)) {
@@ -96,13 +97,37 @@ function filterCategoryTree(
return flat.filter((item) => matchedIds.has(item.id))
}
-function isLastChildBeforeParent(items: FlatCategoryOption[], index: number): boolean {
+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 (
+
+ {Array.from({ length: depth }, (_, level) => {
+ const isBranchLevel = level === depth - 1
+ return (
+
+ )
+ })}
+
+ )
+}
+
export function CategorySearchSelect({
options,
value,
@@ -110,6 +135,8 @@ export function CategorySearchSelect({
disabled = false,
placeholder,
id,
+ allowEmpty = false,
+ dropdownVariant = 'overlay',
}: CategorySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -152,6 +179,13 @@ export function CategorySearchSelect({
setQuery('')
}
+ const dropdownClassName = [
+ styles.dropdown,
+ dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
+ ]
+ .filter(Boolean)
+ .join(' ')
+
return (
- {value && !open && !disabled ? (
+ {allowEmpty && value && !open && !disabled ? (
{open && !disabled ? (
-
+
{filtered.length === 0 ? (
- {t('myProducts.fields.noCategories')}
) : (
- filtered.map((option, index) => (
- -
- {
+ const isLastChild = isLastChildInGroup(filtered, index)
+ return (
+
- 0 ? styles.optionChild : '',
- isLastChildBeforeParent(filtered, index) ? styles.optionChildGroupEnd : '',
- value === option.id ? styles.optionSelected : '',
+ styles.optionItem,
+ isLastChild ? styles.optionItemGroupEnd : '',
]
.filter(Boolean)
.join(' ')}
- style={
- option.depth > 0
- ? {
- marginInlineStart: `${option.depth * 28}px`,
- }
- : undefined
- }
- onClick={() => selectOption(option.id)}
- role="option"
- aria-selected={value === option.id}
>
-
- {categoryLabel(option, isFa)}
-
- {!isFa && option.nameFa ? (
- {option.nameFa}
- ) : null}
- {isFa && option.name && option.name !== option.nameFa ? (
-
- {option.name}
+ selectOption(option.id)}
+ role="option"
+ aria-selected={value === option.id}
+ >
+ {option.depth > 0 ? (
+
+ ) : null}
+
+
+ {categoryLabel(option, isFa)}
+
+ {!isFa && option.nameFa ? (
+ {option.nameFa}
+ ) : null}
+ {isFa && option.name && option.name !== option.nameFa ? (
+
+ {option.name}
+
+ ) : null}
- ) : null}
-
-
- ))
+
+
+ )
+ })
)}
) : null}