diff --git a/apps/business/src/components/AddProductByAiModal.tsx b/apps/business/src/components/AddProductByAiModal.tsx index 823c318..30e6384 100644 --- a/apps/business/src/components/AddProductByAiModal.tsx +++ b/apps/business/src/components/AddProductByAiModal.tsx @@ -165,6 +165,8 @@ export function AddProductByAiModal({ value={categoryId} onChange={setCategoryId} placeholder="Search and select category..." + allowEmpty={false} + dropdownVariant="inFlow" /> diff --git a/apps/business/src/components/CategoryModal.tsx b/apps/business/src/components/CategoryModal.tsx index 9a7d369..911f743 100644 --- a/apps/business/src/components/CategoryModal.tsx +++ b/apps/business/src/components/CategoryModal.tsx @@ -121,6 +121,7 @@ export function CategoryModal({ value={parentId} onChange={setParentId} placeholder={t('categories.select.search')} + dropdownVariant="inFlow" /> diff --git a/apps/business/src/components/CategorySearchSelect.module.css b/apps/business/src/components/CategorySearchSelect.module.css index 6845fc7..ae4cd6a 100644 --- a/apps/business/src/components/CategorySearchSelect.module.css +++ b/apps/business/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/business/src/components/CategorySearchSelect.tsx b/apps/business/src/components/CategorySearchSelect.tsx index 79dede8..9bd358e 100644 --- a/apps/business/src/components/CategorySearchSelect.tsx +++ b/apps/business/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 ? ( - - )) + + + ) + }) )} ) : null} diff --git a/apps/business/src/components/CategoryTree.module.css b/apps/business/src/components/CategoryTree.module.css index e4e1d43..7fe963f 100644 --- a/apps/business/src/components/CategoryTree.module.css +++ b/apps/business/src/components/CategoryTree.module.css @@ -12,7 +12,7 @@ overflow: hidden; margin-inline-start: 28px; margin-bottom: 10px; - padding-inline-start: 16px; - padding-inline-end: 12px; - border-inline-end: 2px solid rgba(var(--primary-rgb) / 0.38); + padding-inline-start: 22px; + padding-inline-end: 8px; + border-inline-start: 1px solid rgba(148, 163, 184, 0.55); } diff --git a/apps/business/src/components/CategoryTreeSelect.module.css b/apps/business/src/components/CategoryTreeSelect.module.css index 085cbf4..71c3f88 100644 --- a/apps/business/src/components/CategoryTreeSelect.module.css +++ b/apps/business/src/components/CategoryTreeSelect.module.css @@ -1,4 +1,5 @@ .wrapper { + position: relative; display: flex; flex-direction: column; min-height: 0; @@ -74,13 +75,26 @@ } .panel { - max-height: min(42vh, 320px); + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 120; + max-height: min(320px, 50vh); overflow-y: auto; - margin-top: 6px; + margin-top: 0; padding: 4px; - background: rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.98); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); + box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14); +} + +.panelInFlow { + position: static; + margin-top: 6px; + max-height: min(42vh, 320px); + z-index: auto; } .row { diff --git a/apps/business/src/components/DailyActivityChart.module.css b/apps/business/src/components/DailyActivityChart.module.css index a7cd7a5..42820e7 100644 --- a/apps/business/src/components/DailyActivityChart.module.css +++ b/apps/business/src/components/DailyActivityChart.module.css @@ -106,6 +106,10 @@ position: relative; flex: 1; min-width: 0; + overflow: hidden; +} + +.chartWrapScroll { overflow-x: auto; } @@ -130,37 +134,55 @@ display: flex; flex-direction: row; align-items: flex-end; - gap: 4px; - min-width: 100%; + width: 100%; padding-bottom: 4px; direction: ltr; unicode-bidi: isolate; } +.chartDay { + gap: 1px; +} + +.chartMonth { + gap: 6px; + overflow-x: auto; +} + .group { - flex: 1; - min-width: 12px; + flex: 1 1 0; + min-width: 0; display: flex; flex-direction: column; align-items: center; - gap: 8px; + gap: 6px; +} + +.chartDay .group { + gap: 4px; } .chartMonth .group { + flex: 1; min-width: 36px; } .bars { display: flex; align-items: flex-end; + justify-content: center; + width: 100%; height: 180px; - gap: 2px; + gap: 1px; } .barWrap { position: relative; display: flex; + flex: 1 1 0; + min-width: 0; align-items: flex-end; + justify-content: center; height: 100%; } @@ -196,12 +218,23 @@ } .bar { - width: 7px; - border-radius: 4px 4px 2px 2px; + flex: 1 1 0; + width: 100%; + max-width: 8px; + min-width: 2px; + border-radius: 3px 3px 1px 1px; transition: height 0.3s ease; cursor: default; } +.chartMonth .bar { + flex: none; + width: 10px; + max-width: none; + min-width: 10px; + border-radius: 4px 4px 2px 2px; +} + .barWrap:hover .bar, .barWrap:focus-within .bar { filter: brightness(1.06); @@ -209,6 +242,23 @@ .barMonth { width: 10px; + max-width: 10px; +} + +.label { + font-size: 9px; + color: var(--text-muted); + font-weight: 500; + font-family: var(--font-ui); + line-height: 1; + text-align: center; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.chartMonth .label { + font-size: 10px; } .barPrimary { @@ -227,15 +277,6 @@ ); } -.label { - font-size: 10px; - color: var(--text-muted); - font-weight: 500; - font-family: var(--font-ui); - line-height: 1; - text-align: center; -} - @media (max-width: 768px) { .header { flex-direction: column; diff --git a/apps/business/src/components/DailyActivityChart.tsx b/apps/business/src/components/DailyActivityChart.tsx index 4303733..62fad13 100644 --- a/apps/business/src/components/DailyActivityChart.tsx +++ b/apps/business/src/components/DailyActivityChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useLocale } from '@meshkee/dashboard-ui' import { ApiError } from '../lib/api' import type { @@ -12,7 +12,6 @@ import type { BusinessMessageKey } from '../i18n/messages' import styles from './DailyActivityChart.module.css' const CHART_HEIGHT = 180 -const BAR_GAP = 2 interface DailyActivityChartProps { titleKey: BusinessMessageKey @@ -90,7 +89,6 @@ export function DailyActivityChart({ const [secondaryTotal, setSecondaryTotal] = useState(0) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState('') - const chartWrapRef = useRef(null) useEffect(() => { const controller = new AbortController() @@ -146,13 +144,6 @@ export function DailyActivityChart({ const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue]) const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1 - useEffect(() => { - if (isLoading || error) return - const wrap = chartWrapRef.current - if (!wrap) return - wrap.scrollLeft = wrap.scrollWidth - }, [isLoading, error, primaryItems, monthItems, granularity]) - const subtitle = useMemo(() => { if (granularity === 'month') { if (monthItems.length === 0) return t(subtitleKey, { from: '…', to: '…' }) @@ -209,7 +200,14 @@ export function DailyActivityChart({ ))}
-
+
-
+
-
+
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) => ( -
    • - -
    • - )) + ) : ( + <> + {cat.nameEn} + {cat.nameFa ? ( + <> + · + {cat.nameFa} + + ) : null} + + )} + + + + ) + }) )}
    - )} + ) : 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 ? ( - - )) + + + ) + }) )} ) : null}