From a412cb82fc8e1e3f26aefe4d7b2865181bfe1656 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 28 Aug 2026 20:39:43 +0330 Subject: [PATCH] Improve dashboard charts, category tree selects, and product AI name fill. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix RTL chart ordering with Y-axis and hover values; aggregate website views; refine category picker tree borders and spacing; add one-click FA→EN name translation on add product. Co-authored-by: Cursor --- .../src/components/AddVariationModal.tsx | 105 ++++++- .../CategorySearchSelect.module.css | 14 +- .../src/components/CategorySearchSelect.tsx | 12 +- .../src/components/CategoryTree.module.css | 8 +- .../CreateStoreItemsModal.module.css | 6 +- .../components/DailyActivityChart.module.css | 96 +++++++ .../src/components/DailyActivityChart.tsx | 270 ++++++++++++------ .../business/src/components/HomeChartSlot.tsx | 2 +- .../ProductActivityChart.module.css | 96 +++++++ .../src/components/ProductActivityChart.tsx | 110 +++++-- .../components/ProductFieldAiPromptModal.tsx | 4 +- .../components/SearchableSelect.module.css | 25 +- .../src/components/SearchableSelect.tsx | 27 +- .../src/components/StoreItemDiscountModal.tsx | 105 +++++-- .../src/components/VariationsModal.module.css | 85 ++++++ .../src/components/VariationsModal.tsx | 11 +- apps/business/src/data/colorPresets.ts | 147 ++++++++++ apps/business/src/i18n/messages.ts | 155 ++++++++-- .../src/pages/AddNewProductPage.module.css | 12 + apps/business/src/pages/AddNewProductPage.tsx | 67 ++++- apps/business/src/pages/CategoriesPage.tsx | 15 +- apps/business/src/pages/LoginPage.tsx | 5 + .../src/services/dailyActivityService.ts | 1 + .../business/src/services/productAiService.ts | 6 +- apps/business/src/types/variation.ts | 10 +- apps/business/src/utils/chartAxis.ts | 17 ++ .../CategorySearchSelect.module.css | 14 +- .../src/components/CategorySearchSelect.tsx | 12 +- apps/customer/src/i18n/messages.ts | 4 + apps/customer/src/pages/LoginPage.tsx | 5 + docs/PROJECT_CONTEXT.md | 2 +- 31 files changed, 1222 insertions(+), 226 deletions(-) create mode 100644 apps/business/src/data/colorPresets.ts create mode 100644 apps/business/src/utils/chartAxis.ts diff --git a/apps/business/src/components/AddVariationModal.tsx b/apps/business/src/components/AddVariationModal.tsx index 3b4c75d..176e9aa 100644 --- a/apps/business/src/components/AddVariationModal.tsx +++ b/apps/business/src/components/AddVariationModal.tsx @@ -1,11 +1,12 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Plus, X } from 'lucide-react' +import { Plus, Search, X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import type { BusinessMessageKey } from '../i18n/messages' +import { COLOR_PRESETS } from '../data/colorPresets' import type { Variation, VariationType } from '../types/variation' -import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation' +import { SIZE_OPTIONS, normalizeColorNames } from '../types/variation' import { Tooltip } from './Tooltip' import styles from './VariationsModal.module.css' @@ -24,6 +25,10 @@ function seedCustomValues(values: string[]) { return trimmed.length ? trimmed : [''] } +function colorLabelKey(name: string): BusinessMessageKey { + return `categories.variations.color.${name}` as BusinessMessageKey +} + export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) { const t = useT() const { locale } = useLocale() @@ -36,6 +41,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A const [selectedColors, setSelectedColors] = useState([]) const [selectedSizes, setSelectedSizes] = useState([]) const [customValues, setCustomValues] = useState(['']) + const [colorQuery, setColorQuery] = useState('') const valueInputRefs = useRef<(HTMLInputElement | null)[]>([]) const pendingFocusIndex = useRef(null) @@ -49,10 +55,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A if (open) { setMounted(true) setClosing(false) + setColorQuery('') if (initial) { setType(initial.type) setCustomName(initial.type === 'custom' ? initial.name : '') - setSelectedColors(initial.type === 'color' ? [...initial.values] : []) + setSelectedColors( + initial.type === 'color' ? normalizeColorNames(initial.values) : [], + ) setSelectedSizes(initial.type === 'size' ? [...initial.values] : []) setCustomValues( initial.type === 'custom' ? seedCustomValues(initial.values) : [''], @@ -83,12 +92,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A return () => document.removeEventListener('keydown', onKey) }, [mounted, closing, onClose]) + const filteredColors = useMemo(() => { + const q = colorQuery.trim().toLowerCase() + if (!q) return COLOR_PRESETS + return COLOR_PRESETS.filter((color) => { + const en = color.name.toLowerCase() + const fa = t(colorLabelKey(color.name)).toLowerCase() + return en.includes(q) || fa.includes(q) + }) + }, [colorQuery, t]) + if (!mounted) return null function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) { setSelected( selected.includes(value) - ? selected.filter((v) => v !== value) + ? selected.filter((item) => item !== value) : [...selected, value], ) } @@ -180,18 +199,78 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A {type === 'color' && (
- -
- {COLOR_OPTIONS.map((color) => ( + +
+
+ {selectedColors.length > 0 ? ( +
+ {selectedColors.map((color) => { + const preset = COLOR_PRESETS.find((item) => item.name === color) + return ( + + ) + })} +
+ ) : null} +
+ {filteredColors.length === 0 ? ( +

{t('categories.variations.noColorMatch')}

+ ) : ( + filteredColors.map((color) => ( + + )) + )}
)} diff --git a/apps/business/src/components/CategorySearchSelect.module.css b/apps/business/src/components/CategorySearchSelect.module.css index 020646a..6845fc7 100644 --- a/apps/business/src/components/CategorySearchSelect.module.css +++ b/apps/business/src/components/CategorySearchSelect.module.css @@ -113,11 +113,15 @@ } .optionChild { - border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.35); - border-start-start-radius: 0; - border-end-start-radius: 0; + 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-start: 14px; + padding-inline-end: 14px; +} + +.optionChildGroupEnd { + margin-bottom: 10px; } .option:hover { @@ -135,7 +139,7 @@ } .optionChild.optionSelected { - border-inline-start-color: var(--primary); + border-inline-end-color: var(--primary); background: rgba(var(--primary-rgb) / 0.14); } diff --git a/apps/business/src/components/CategorySearchSelect.tsx b/apps/business/src/components/CategorySearchSelect.tsx index 34f3f6a..79dede8 100644 --- a/apps/business/src/components/CategorySearchSelect.tsx +++ b/apps/business/src/components/CategorySearchSelect.tsx @@ -96,6 +96,13 @@ function filterCategoryTree( return flat.filter((item) => matchedIds.has(item.id)) } +function isLastChildBeforeParent(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 +} + export function CategorySearchSelect({ options, value, @@ -202,13 +209,14 @@ export function CategorySearchSelect({ {filtered.length === 0 ? (
  • {t('customerProducts.form.fields.noCategories')}
  • ) : ( - filtered.map((option) => ( + filtered.map((option, index) => (
  • - {rows.map((row) => ( -
    - {row.label} -
    - + {rows.map((row) => { + const hasDiscount = Boolean(row.discountedPrice.trim()) + return ( +
    + {row.label} +
    + +
    + + setRows((prev) => + prev.map((entry) => + entry.id === row.id + ? { ...entry, discountedPrice: formatIrtInput(e.target.value) } + : entry, + ), + ) + } + placeholder={t('storeItems.discount.placeholder')} + disabled={isSubmitting} + /> +
    + + + +
    - - setRows((prev) => - prev.map((entry) => - entry.id === row.id - ? { ...entry, discountedPrice: formatIrtInput(e.target.value) } - : entry, - ), - ) - } - placeholder={t('storeItems.discount.placeholder')} - disabled={isSubmitting} - /> -
    - ))} + ) + })}
    {error &&

    {error}

    } diff --git a/apps/business/src/components/VariationsModal.module.css b/apps/business/src/components/VariationsModal.module.css index 924f872..4b63d91 100644 --- a/apps/business/src/components/VariationsModal.module.css +++ b/apps/business/src/components/VariationsModal.module.css @@ -249,9 +249,15 @@ display: flex; flex-wrap: wrap; gap: 8px; + max-height: min(42vh, 360px); + overflow-y: auto; + padding: 2px; } .chip { + display: inline-flex; + align-items: center; + gap: 7px; padding: 7px 14px; font-size: 13px; font-weight: 500; @@ -273,6 +279,85 @@ color: var(--primary); } +.colorSearchWrap { + position: relative; + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.colorSearchIcon { + position: absolute; + inset-inline-start: 12px; + color: var(--text-muted); + pointer-events: none; +} + +.colorSearchInput { + width: 100%; + min-height: var(--field-height); + padding: var(--field-padding-y) 36px; + font-size: var(--field-font-size); + font-family: var(--font-ui); + color: var(--text-primary); + background-color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + outline: none; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.colorSearchInput::placeholder { + font-family: var(--font-ui); + opacity: 1; + color: var(--text-muted); +} + +.colorSearchInput:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.colorSearchClear { + position: absolute; + inset-inline-end: 8px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + border-radius: 8px; +} + +.colorSearchClear:hover { + background: rgba(148, 163, 184, 0.15); + color: var(--text-primary); +} + +.selectedColors { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.colorSwatch { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +.colorEmpty { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + .customValues { display: flex; flex-direction: column; diff --git a/apps/business/src/components/VariationsModal.tsx b/apps/business/src/components/VariationsModal.tsx index 746f0d5..a6d34d7 100644 --- a/apps/business/src/components/VariationsModal.tsx +++ b/apps/business/src/components/VariationsModal.tsx @@ -5,7 +5,7 @@ import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import type { BusinessMessageKey } from '../i18n/messages' import type { Variation, VariationType } from '../types/variation' -import { COLOR_OPTIONS } from '../types/variation' +import { COLOR_OPTIONS, normalizeColorName } from '../types/variation' import { createId } from '../utils/id' import { textLocaleAttrs } from '../utils/textLocale' import { AddVariationModal } from './AddVariationModal' @@ -18,6 +18,7 @@ interface VariationsModalProps { categoryName: string variations: Variation[] isSaving?: boolean + error?: string onClose: () => void onChange: (variations: Variation[]) => void onSave: () => void @@ -30,6 +31,7 @@ export function VariationsModal({ categoryName, variations, isSaving = false, + error = '', onClose, onChange, onSave, @@ -120,8 +122,9 @@ export function VariationsModal({ } function localizeValue(value: string) { - if ((COLOR_OPTIONS as string[]).includes(value)) { - return t(`categories.variations.color.${value}` as BusinessMessageKey) + const normalized = normalizeColorName(value) + if ((COLOR_OPTIONS as string[]).includes(normalized)) { + return t(`categories.variations.color.${normalized}` as BusinessMessageKey) } return value } @@ -223,6 +226,8 @@ export function VariationsModal({ {t('categories.variations.add')} + {error &&

    {error}

    } +
    + + +
    diff --git a/apps/business/src/pages/CategoriesPage.tsx b/apps/business/src/pages/CategoriesPage.tsx index 7060bdc..86ea836 100644 --- a/apps/business/src/pages/CategoriesPage.tsx +++ b/apps/business/src/pages/CategoriesPage.tsx @@ -60,6 +60,7 @@ export function CategoriesPage() { const [categoryVariations, setCategoryVariations] = useState>({}) const [variationsLoading, setVariationsLoading] = useState(false) const [variationsSaving, setVariationsSaving] = useState(false) + const [variationsError, setVariationsError] = useState('') const [technicalTarget, setTechnicalTarget] = useState<{ categoryId: string categoryName: string @@ -233,7 +234,7 @@ export function CategoriesPage() { : category.nameEn, }) setVariationsLoading(true) - setError('') + setVariationsError('') try { const items = await listCategoryVariations(categoryId) @@ -241,9 +242,9 @@ export function CategoriesPage() { setVariationCount(categoryId, items.length) } catch (err) { if (err instanceof ApiError) { - setError(err.message) + setVariationsError(err.message) } else { - setError('Unable to load category variations.') + setVariationsError('Unable to load category variations.') } } finally { setVariationsLoading(false) @@ -394,7 +395,7 @@ export function CategoriesPage() { if (!variationsTarget) return setVariationsSaving(true) - setError('') + setVariationsError('') try { const saved = await saveCategoryVariations( @@ -409,9 +410,9 @@ export function CategoriesPage() { setVariationsTarget(null) } catch (err) { if (err instanceof ApiError) { - setError(err.message) + setVariationsError(err.message) } else { - setError('Unable to save category variations.') + setVariationsError('Unable to save category variations.') } } finally { setVariationsSaving(false) @@ -514,8 +515,10 @@ export function CategoriesPage() { : getVariationsForCategory(variationsTarget.categoryId) } isSaving={variationsSaving || variationsLoading} + error={variationsError} onClose={() => !variationsSaving && setVariationsTarget(null)} onChange={(variations) => { + setVariationsError('') setCategoryVariations((prev) => ({ ...prev, [variationsTarget.categoryId]: variations, diff --git a/apps/business/src/pages/LoginPage.tsx b/apps/business/src/pages/LoginPage.tsx index 1844ca2..fe1dce9 100644 --- a/apps/business/src/pages/LoginPage.tsx +++ b/apps/business/src/pages/LoginPage.tsx @@ -91,6 +91,11 @@ export function LoginPage() { err.message.includes('Invalid cell number or password') ) { setError(t('login.error.invalidCredentials')) + } else if ( + err.message.includes('Cell number is not verified') || + err.message.includes('complete OTP verification') + ) { + setError(t('login.error.cellNotVerified')) } else { setError(err.message) } diff --git a/apps/business/src/services/dailyActivityService.ts b/apps/business/src/services/dailyActivityService.ts index 6ed3b7c..75ad186 100644 --- a/apps/business/src/services/dailyActivityService.ts +++ b/apps/business/src/services/dailyActivityService.ts @@ -42,6 +42,7 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal) export type WebsiteViewKind = | 'home' + | 'all' | 'portfolio_list' | 'portfolio_detail' | 'product_list' diff --git a/apps/business/src/services/productAiService.ts b/apps/business/src/services/productAiService.ts index 3ec7f9d..90fc333 100644 --- a/apps/business/src/services/productAiService.ts +++ b/apps/business/src/services/productAiService.ts @@ -4,7 +4,8 @@ import type { ProductApi } from './productService' export type ProductAiLanguage = 'en' | 'fa' -export type ProductAiFillField = 'summary' | 'description' +export type ProductAiFillField = 'summary' | 'description' | 'nameEn' +export type ProductAiPromptField = Extract export interface CreateProductByAiPayload { categoryId: string @@ -14,7 +15,7 @@ export interface CreateProductByAiPayload { export interface FillProductFieldByAiPayload { field: ProductAiFillField - prompt: string + prompt?: string title?: string nameFa?: string categoryId?: string @@ -45,6 +46,7 @@ export async function fillProductFieldByAi(payload: FillProductFieldByAiPayload) return apiRequest< | { field: 'summary'; text: string } | { field: 'description'; html: string } + | { field: 'nameEn'; text: string } >(`/businesses/${businessId}/products/ai-fill`, { method: 'POST', auth: true, diff --git a/apps/business/src/types/variation.ts b/apps/business/src/types/variation.ts index 6a84eb7..79bd4e1 100644 --- a/apps/business/src/types/variation.ts +++ b/apps/business/src/types/variation.ts @@ -1,3 +1,12 @@ +export type { ColorPresetName } from '../data/colorPresets' +export { + COLOR_OPTIONS, + COLOR_PRESETS, + getColorHex, + normalizeColorName, + normalizeColorNames, +} from '../data/colorPresets' + export type VariationType = 'color' | 'size' | 'custom' export interface Variation { @@ -13,5 +22,4 @@ export const DEFAULT_VARIATIONS: Variation[] = [ { id: 'default-guarantee', name: 'Guarantee', type: 'custom', values: ['1 Year', '2 Years'] }, ] -export const COLOR_OPTIONS = ['Red', 'Blue', 'Green', 'Black', 'White', 'Silver', 'Gold', 'Navy'] export const SIZE_OPTIONS = ['XS', 'S', 'M', 'L', 'XL', 'XXL', '64GB', '128GB', '256GB', '512GB', '1TB'] diff --git a/apps/business/src/utils/chartAxis.ts b/apps/business/src/utils/chartAxis.ts new file mode 100644 index 0000000..75331b7 --- /dev/null +++ b/apps/business/src/utils/chartAxis.ts @@ -0,0 +1,17 @@ +/** Nice Y-axis ticks from 0 through a ceiling of `max`. */ +export function buildYAxisTicks(max: number, segments = 4): number[] { + if (max <= 0) return [0, 1] + const step = Math.max(1, Math.ceil(max / segments)) + const top = Math.ceil(max / step) * step + const ticks: number[] = [] + for (let value = 0; value <= top; value += step) { + ticks.push(value) + } + return ticks +} + +export function formatChartCount(value: number, locale: string): string { + return value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', { + numberingSystem: 'latn', + }) +} diff --git a/apps/customer/src/components/CategorySearchSelect.module.css b/apps/customer/src/components/CategorySearchSelect.module.css index 020646a..6845fc7 100644 --- a/apps/customer/src/components/CategorySearchSelect.module.css +++ b/apps/customer/src/components/CategorySearchSelect.module.css @@ -113,11 +113,15 @@ } .optionChild { - border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.35); - border-start-start-radius: 0; - border-end-start-radius: 0; + 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-start: 14px; + padding-inline-end: 14px; +} + +.optionChildGroupEnd { + margin-bottom: 10px; } .option:hover { @@ -135,7 +139,7 @@ } .optionChild.optionSelected { - border-inline-start-color: var(--primary); + border-inline-end-color: var(--primary); background: rgba(var(--primary-rgb) / 0.14); } diff --git a/apps/customer/src/components/CategorySearchSelect.tsx b/apps/customer/src/components/CategorySearchSelect.tsx index 7dfdf44..1853c17 100644 --- a/apps/customer/src/components/CategorySearchSelect.tsx +++ b/apps/customer/src/components/CategorySearchSelect.tsx @@ -96,6 +96,13 @@ function filterCategoryTree( return flat.filter((item) => matchedIds.has(item.id)) } +function isLastChildBeforeParent(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 +} + export function CategorySearchSelect({ options, value, @@ -202,13 +209,14 @@ export function CategorySearchSelect({ {filtered.length === 0 ? (
  • {t('myProducts.fields.noCategories')}
  • ) : ( - filtered.map((option) => ( + filtered.map((option, index) => (