Improve dashboard charts, category tree selects, and product AI name fill.

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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-28 20:39:43 +03:30
co-authored by Cursor
parent 7b2ed7c7b0
commit a412cb82fc
31 changed files with 1222 additions and 226 deletions
@@ -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<string[]>([])
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
const [customValues, setCustomValues] = useState<string[]>([''])
const [colorQuery, setColorQuery] = useState('')
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
const pendingFocusIndex = useRef<number | null>(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' && (
<div className={styles.field}>
<label>{t('categories.variations.selectColors')}</label>
<div className={styles.chipGrid}>
{COLOR_OPTIONS.map((color) => (
<label htmlFor="colorSearch">{t('categories.variations.selectColors')}</label>
<div className={styles.colorSearchWrap}>
<Search size={16} className={styles.colorSearchIcon} aria-hidden="true" />
<input
id="colorSearch"
type="search"
className={styles.colorSearchInput}
value={colorQuery}
onChange={(e) => setColorQuery(e.target.value)}
placeholder={t('categories.variations.searchColors')}
autoComplete="off"
/>
{colorQuery ? (
<button
key={color}
type="button"
className={`${styles.chip} ${selectedColors.includes(color) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(color, selectedColors, setSelectedColors)}
className={styles.colorSearchClear}
onClick={() => setColorQuery('')}
aria-label={t('common.close')}
>
{t(`categories.variations.color.${color}` as BusinessMessageKey)}
<X size={14} />
</button>
))}
) : null}
</div>
{selectedColors.length > 0 ? (
<div className={styles.selectedColors}>
{selectedColors.map((color) => {
const preset = COLOR_PRESETS.find((item) => item.name === color)
return (
<button
key={color}
type="button"
className={`${styles.chip} ${styles.chipSelected}`}
onClick={() =>
toggleOption(color, selectedColors, setSelectedColors)
}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: preset?.hex ?? '#94a3b8' }}
aria-hidden="true"
/>
{t(colorLabelKey(color))}
<X size={12} aria-hidden="true" />
</button>
)
})}
</div>
) : null}
<div className={styles.chipGrid}>
{filteredColors.length === 0 ? (
<p className={styles.colorEmpty}>{t('categories.variations.noColorMatch')}</p>
) : (
filteredColors.map((color) => (
<button
key={color.name}
type="button"
className={`${styles.chip} ${
selectedColors.includes(color.name) ? styles.chipSelected : ''
}`}
onClick={() =>
toggleOption(color.name, selectedColors, setSelectedColors)
}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: color.hex }}
aria-hidden="true"
/>
{t(colorLabelKey(color.name))}
</button>
))
)}
</div>
</div>
)}
@@ -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);
}
@@ -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 ? (
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
) : (
filtered.map((option) => (
filtered.map((option, index) => (
<li key={option.id}>
<button
type="button"
className={[
styles.option,
option.depth > 0 ? styles.optionChild : '',
isLastChildBeforeParent(filtered, index) ? styles.optionChildGroupEnd : '',
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
@@ -216,7 +224,7 @@ export function CategorySearchSelect({
style={
option.depth > 0
? {
marginInlineStart: `${option.depth * 22}px`,
marginInlineStart: `${option.depth * 28}px`,
}
: undefined
}
@@ -10,7 +10,9 @@
.childrenInner {
overflow: hidden;
margin-inline-start: 20px;
padding-inline-start: 20px;
border-inline-start: 2px solid rgba(148, 163, 184, 0.3);
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);
}
@@ -171,11 +171,15 @@
.discountHeader,
.discountRow {
display: grid;
grid-template-columns: minmax(140px, 1.4fr) minmax(120px, 1fr) minmax(160px, 1fr);
grid-template-columns: minmax(140px, 1.4fr) minmax(120px, 1fr) minmax(160px, 1fr) 32px;
gap: 10px;
align-items: center;
}
.discountRemoveHeader {
width: 32px;
}
.discountHeaderFestival,
.discountRowFestival {
display: grid;
@@ -73,16 +73,68 @@
color: #dc2626;
}
.chartPanel {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px;
direction: ltr;
unicode-bidi: isolate;
}
.yAxis {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-shrink: 0;
width: 40px;
padding-bottom: 28px;
box-sizing: border-box;
direction: ltr;
}
.yTick {
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-ui);
line-height: 1;
text-align: right;
}
.chartWrap {
position: relative;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.chartGrid {
position: absolute;
inset-inline: 0;
bottom: 28px;
pointer-events: none;
z-index: 0;
}
.gridLine {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed color-mix(in srgb, var(--text-muted) 28%, transparent);
}
.chart {
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 4px;
min-width: 100%;
padding-bottom: 4px;
direction: ltr;
unicode-bidi: isolate;
}
.group {
@@ -105,10 +157,54 @@
gap: 2px;
}
.barWrap {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
}
.barWrap:hover .barTooltip,
.barWrap:focus-within .barTooltip {
opacity: 1;
visibility: visible;
}
.barTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
font-family: var(--font-ui);
line-height: 1.35;
color: var(--text-primary);
white-space: nowrap;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(31, 38, 135, 0.12);
z-index: 4;
}
.bar {
width: 7px;
border-radius: 4px 4px 2px 2px;
transition: height 0.3s ease;
cursor: default;
}
.barWrap:hover .bar,
.barWrap:focus-within .bar {
filter: brightness(1.06);
}
.barMonth {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import type {
@@ -6,6 +6,7 @@ import type {
DualDailyActivityResponse,
} from '../services/dailyActivityService'
import { aggregateDailyActivityByMonth } from '../utils/monthlyActivity'
import { buildYAxisTicks, formatChartCount } from '../utils/chartAxis'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
@@ -29,15 +30,45 @@ interface DailyActivityChartProps {
showSecondary?: boolean
}
function formatDayLabel(dateKey: string, locale: string): string {
function parseDateKey(dateKey: string): Date {
const [year, month, day] = dateKey.split('-').map(Number)
const date = new Date(year, month - 1, day)
return date.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
return new Date(year, month - 1, day)
}
function formatDayLabel(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric',
numberingSystem: 'latn',
})
}
/** Tooltip label with month context (e.g. ۶ شهریور / Sep 6). */
function formatDayTooltip(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric',
month: 'short',
numberingSystem: 'latn',
})
}
/** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */
function formatRangeDate(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
/** Month + year for yearly month charts (e.g. شهریور ۱۴۰۵ / September 2026). */
function formatRangeMonth(monthKey: string, locale: string): string {
const [year, month] = monthKey.split('-').map(Number)
return new Date(year, month - 1, 1).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'long',
year: 'numeric',
})
}
export function DailyActivityChart({
titleKey,
subtitleKey,
@@ -59,6 +90,7 @@ export function DailyActivityChart({
const [secondaryTotal, setSecondaryTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const controller = new AbortController()
@@ -111,12 +143,37 @@ export function DailyActivityChart({
return peak > 0 ? peak : 1
}, [granularity, monthItems, primaryItems, secondaryItems])
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: '…' })
return t(subtitleKey, {
from: formatRangeMonth(monthItems[0].monthKey, locale),
to: formatRangeMonth(monthItems[monthItems.length - 1].monthKey, locale),
})
}
if (primaryItems.length === 0) return t(subtitleKey, { from: '…', to: '…' })
return t(subtitleKey, {
from: formatRangeDate(primaryItems[0].date, locale),
to: formatRangeDate(primaryItems[primaryItems.length - 1].date, locale),
})
}, [granularity, locale, monthItems, primaryItems, subtitleKey, t])
return (
<section className={styles.card} aria-label={t(titleKey)}>
<div className={styles.header}>
<div>
<h3 className={styles.title}>{t(titleKey)}</h3>
<p className={styles.subtitle}>{t(subtitleKey)}</p>
<p className={styles.subtitle}>{subtitle}</p>
</div>
<div className={styles.legend}>
<span className={styles.legendItem}>
@@ -139,89 +196,136 @@ export function DailyActivityChart({
{error}
</p>
) : (
<div className={styles.chartWrap}>
<div className={styles.chartPanel} dir="ltr">
<div
className={`${styles.chart}${granularity === 'month' ? ` ${styles.chartMonth}` : ''}`}
className={styles.yAxis}
aria-hidden="true"
style={{ height: CHART_HEIGHT + 28 }}
role="img"
aria-label={t(titleKey)}
>
{granularity === 'month'
? monthItems.map((item) => {
const primaryHeight = (item.primary / maxValue) * CHART_HEIGHT
const secondaryHeight = (item.secondary / maxValue) * CHART_HEIGHT
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary} ${styles.barMonth}`}
style={{
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
}}
title={t(primaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.primary,
})}
/>
{showSecondary ? (
<div
className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`}
style={{
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.secondary,
})}
/>
) : null}
</div>
<span
className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{item.label}
</span>
</div>
)
})
: primaryItems.map((item, index) => {
const secondary = secondaryItems[index]
const secondaryCount = secondary?.count ?? 0
const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
const label = formatDayLabel(item.date, locale)
{[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}>
{formatChartCount(tick, locale)}
</span>
))}
</div>
return (
<div key={item.date} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary}`}
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
title={t(primaryBarTitleKey, { day: label, count: item.count })}
/>
{showSecondary ? (
<div
className={`${styles.bar} ${styles.barAccent}`}
style={{
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: label,
count: secondaryCount,
})}
/>
) : null}
<div className={styles.chartWrap} ref={chartWrapRef}>
<div
className={styles.chartGrid}
style={{ height: CHART_HEIGHT }}
aria-hidden="true"
>
{yAxisTicks.map((tick) => (
<div
key={tick}
className={styles.gridLine}
style={{ bottom: `${(tick / chartScaleMax) * 100}%` }}
/>
))}
</div>
<div
className={`${styles.chart}${granularity === 'month' ? ` ${styles.chartMonth}` : ''}`}
style={{ height: CHART_HEIGHT + 28 }}
role="img"
aria-label={t(titleKey)}
>
{granularity === 'month'
? monthItems.map((item) => {
const primaryHeight = (item.primary / chartScaleMax) * CHART_HEIGHT
const secondaryHeight = (item.secondary / chartScaleMax) * CHART_HEIGHT
const primaryTip = t(primaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.primary,
})
const secondaryTip = t(secondaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.secondary,
})
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barPrimary} ${styles.barMonth}`}
style={{
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
}}
/>
<span className={styles.barTooltip}>{primaryTip}</span>
</div>
{showSecondary ? (
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`}
style={{
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
}}
/>
<span className={styles.barTooltip}>{secondaryTip}</span>
</div>
) : null}
</div>
<span
className={styles.label}
lang={locale === 'fa' ? 'fa' : 'en'}
dir="ltr"
>
{item.label}
</span>
</div>
<span className={styles.label} lang="en" dir="ltr">
{label}
</span>
</div>
)
})}
)
})
: primaryItems.map((item, index) => {
const secondary = secondaryItems[index]
const secondaryCount = secondary?.count ?? 0
const primaryHeight = (item.count / chartScaleMax) * CHART_HEIGHT
const secondaryHeight = (secondaryCount / chartScaleMax) * CHART_HEIGHT
const label = formatDayLabel(item.date, locale)
const tooltipDay = formatDayTooltip(item.date, locale)
const primaryTip = t(primaryBarTitleKey, {
day: tooltipDay,
count: item.count,
})
const secondaryTip = t(secondaryBarTitleKey, {
day: tooltipDay,
count: secondaryCount,
})
return (
<div key={item.date} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barPrimary}`}
style={{
height: Math.max(primaryHeight, item.count > 0 ? 4 : 0),
}}
/>
<span className={styles.barTooltip}>{primaryTip}</span>
</div>
{showSecondary ? (
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barAccent}`}
style={{
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}}
/>
<span className={styles.barTooltip}>{secondaryTip}</span>
</div>
) : null}
</div>
<span className={styles.label} lang="en" dir="ltr">
{label}
</span>
</div>
)
})}
</div>
</div>
</div>
)}
@@ -39,7 +39,7 @@ const VIEW_CHART_CONFIG: Record<
barKey: 'home.chart.productViews.bar',
},
website_views_30d: {
kind: 'home',
kind: 'all',
titleKey: 'home.chart.websiteViews.title',
subtitleKey: 'home.chart.websiteViews.subtitle',
legendKey: 'home.chart.websiteViews.legend',
@@ -71,16 +71,68 @@
color: #dc2626;
}
.chartPanel {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px;
direction: ltr;
unicode-bidi: isolate;
}
.yAxis {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-shrink: 0;
width: 40px;
padding-bottom: 32px;
box-sizing: border-box;
direction: ltr;
}
.yTick {
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-ui);
line-height: 1;
text-align: right;
}
.chartWrap {
position: relative;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.chartGrid {
position: absolute;
inset-inline: 0;
bottom: 32px;
pointer-events: none;
z-index: 0;
}
.gridLine {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed color-mix(in srgb, var(--text-muted) 28%, transparent);
}
.chart {
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 14px;
min-width: 100%;
padding-bottom: 4px;
direction: ltr;
unicode-bidi: isolate;
}
.group {
@@ -98,10 +150,54 @@
height: 200px;
}
.barWrap {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
}
.barWrap:hover .barTooltip,
.barWrap:focus-within .barTooltip {
opacity: 1;
visibility: visible;
}
.barTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
font-family: var(--font-ui);
line-height: 1.35;
color: var(--text-primary);
white-space: nowrap;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(31, 38, 135, 0.12);
z-index: 4;
}
.bar {
width: 12px;
border-radius: 4px 4px 2px 2px;
transition: height 0.3s ease;
cursor: default;
}
.barWrap:hover .bar,
.barWrap:focus-within .bar {
filter: brightness(1.06);
}
.barAdded {
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity'
import { buildYAxisTicks, formatChartCount } from '../utils/chartAxis'
import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css'
@@ -15,6 +16,7 @@ export function ProductActivityChart() {
const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const controller = new AbortController()
@@ -46,6 +48,16 @@ export function ProductActivityChart() {
return peak > 0 ? peak : 1
}, [data])
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, data])
const totals = useMemo(
() => ({
added: data.reduce((sum, item) => sum + item.added, 0),
@@ -80,41 +92,77 @@ export function ProductActivityChart() {
{error}
</p>
) : (
<div className={styles.chartWrap}>
<div className={styles.chartPanel} dir="ltr">
<div
className={styles.chart}
className={styles.yAxis}
aria-hidden="true"
style={{ height: CHART_HEIGHT + 32 }}
role="img"
aria-label={t('products.activity.chartAria')}
>
{data.map((item) => {
const addedHeight = (item.added / maxValue) * CHART_HEIGHT
const updatedHeight = (item.updated / maxValue) * CHART_HEIGHT
{[...yAxisTicks].reverse().map((tick) => (
<span key={tick} className={styles.yTick}>
{formatChartCount(tick, locale)}
</span>
))}
</div>
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barAdded}`}
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }}
title={t('products.activity.barAdded', {
month: item.label,
count: item.added,
})}
/>
<div
className={`${styles.bar} ${styles.barUpdated}`}
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }}
title={t('products.activity.barUpdated', {
month: item.label,
count: item.updated,
})}
/>
<div className={styles.chartWrap} ref={chartWrapRef}>
<div
className={styles.chartGrid}
style={{ height: CHART_HEIGHT }}
aria-hidden="true"
>
{yAxisTicks.map((tick) => (
<div
key={tick}
className={styles.gridLine}
style={{ bottom: `${(tick / chartScaleMax) * 100}%` }}
/>
))}
</div>
<div
className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }}
role="img"
aria-label={t('products.activity.chartAria')}
>
{data.map((item) => {
const addedHeight = (item.added / chartScaleMax) * CHART_HEIGHT
const updatedHeight = (item.updated / chartScaleMax) * CHART_HEIGHT
const addedTip = t('products.activity.barAdded', {
month: item.label,
count: item.added,
})
const updatedTip = t('products.activity.barUpdated', {
month: item.label,
count: item.updated,
})
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barAdded}`}
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }}
/>
<span className={styles.barTooltip}>{addedTip}</span>
</div>
<div className={styles.barWrap}>
<div
className={`${styles.bar} ${styles.barUpdated}`}
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }}
/>
<span className={styles.barTooltip}>{updatedTip}</span>
</div>
</div>
<span className={styles.label} dir="ltr">
{item.label}
</span>
</div>
<span className={styles.label}>{item.label}</span>
</div>
)
})}
)
})}
</div>
</div>
</div>
)}
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { ProductAiFillField } from '../services/productAiService'
import type { ProductAiPromptField } from '../services/productAiService'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
@@ -104,7 +104,7 @@ Requirements:
interface ProductFieldAiPromptModalProps {
open: boolean
field: ProductAiFillField
field: ProductAiPromptField
nameFa: string
nameEn: string
categoryName: string
@@ -112,24 +112,47 @@
align-items: center;
gap: 6px;
width: 100%;
padding: 7px 10px;
padding: 8px 12px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-primary);
border-radius: 8px;
transition: background 0.15s;
box-sizing: border-box;
}
.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;
}
.optionChildGroupEnd {
margin-bottom: 10px;
}
.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);
}
.optionEn {
font-weight: 500;
text-align: start;
@@ -13,6 +13,16 @@ interface SearchableSelectProps {
allowEmpty?: boolean
}
function isLastChildBeforeParent<T extends { depth: number }>(
items: T[],
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 categoryLabel(cat: FlatCategory, isFa: boolean) {
if (isFa) {
return cat.nameFa
@@ -120,12 +130,23 @@ export function SearchableSelect({
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('categories.select.empty')}</li>
) : (
filtered.map((cat) => (
filtered.map((cat, index) => (
<li key={cat.id}>
<button
type="button"
className={`${styles.option} ${value === cat.id ? styles.optionSelected : ''}`}
style={{ paddingInlineStart: `${14 + cat.depth * 16}px` }}
className={[
styles.option,
cat.depth > 0 ? styles.optionChild : '',
isLastChildBeforeParent(filtered, index) ? styles.optionChildGroupEnd : '',
value === cat.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
style={
cat.depth > 0
? { marginInlineStart: `${cat.depth * 28}px` }
: undefined
}
onClick={() => selectOption(cat.id)}
>
{isFa ? (
@@ -11,6 +11,7 @@ import {
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { createId } from '../utils/id'
import { StoreItemPrice } from './StoreItemPrice'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css'
@@ -68,6 +69,7 @@ export function StoreItemDiscountModal({
})),
)
setError('')
setIsSubmitting(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -76,7 +78,9 @@ export function StoreItemDiscountModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, items])
// Seed drafts only when the modal opens — not when `items` refresh after a remove.
// eslint-disable-next-line react-hooks/exhaustive-deps -- items captured on open
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
@@ -100,6 +104,40 @@ export function StoreItemDiscountModal({
return discounted < row.price
})
async function handleRemoveDiscount(row: DiscountDraftRow) {
if (!productId || isSubmitting || !row.discountedPrice.trim()) return
const saved = items.find((item) => item.id === row.itemId)
const hadSavedDiscount = saved?.discountedPrice != null
setRows((prev) =>
prev.map((entry) =>
entry.id === row.id ? { ...entry, discountedPrice: '' } : entry,
),
)
if (!hadSavedDiscount) return
setIsSubmitting(true)
setError('')
try {
const data = await updateStoreItemDiscounts({
productId,
items: [{ id: row.itemId, discountedPrice: null }],
})
onSaved?.(data.items)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.discount.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit || !productId) return
@@ -159,34 +197,51 @@ export function StoreItemDiscountModal({
<span>{t('storeItems.discount.variant')}</span>
<span>{t('storeItems.discount.regularPrice')}</span>
<span>{t('storeItems.discount.discountedPrice')}</span>
<span className={rowStyles.discountRemoveHeader} aria-hidden="true" />
</div>
<div className={rowStyles.discountRows}>
{rows.map((row) => (
<div key={row.id} className={rowStyles.discountRow}>
<span className={rowStyles.discountLabel}>{row.label}</span>
<div className={rowStyles.discountPricePreview}>
<StoreItemPrice price={row.price} />
{rows.map((row) => {
const hasDiscount = Boolean(row.discountedPrice.trim())
return (
<div key={row.id} className={rowStyles.discountRow}>
<span className={rowStyles.discountLabel}>{row.label}</span>
<div className={rowStyles.discountPricePreview}>
<StoreItemPrice price={row.price} />
</div>
<input
type="text"
inputMode="numeric"
className={rowStyles.compactInput}
value={row.discountedPrice}
onChange={(e) =>
setRows((prev) =>
prev.map((entry) =>
entry.id === row.id
? { ...entry, discountedPrice: formatIrtInput(e.target.value) }
: entry,
),
)
}
placeholder={t('storeItems.discount.placeholder')}
disabled={isSubmitting}
/>
<div className={rowStyles.removeCell}>
<Tooltip label={t('storeItems.discount.remove')}>
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => void handleRemoveDiscount(row)}
disabled={!hasDiscount || isSubmitting}
aria-label={t('storeItems.discount.remove')}
>
<X size={16} />
</button>
</Tooltip>
</div>
</div>
<input
type="text"
inputMode="numeric"
className={rowStyles.compactInput}
value={row.discountedPrice}
onChange={(e) =>
setRows((prev) =>
prev.map((entry) =>
entry.id === row.id
? { ...entry, discountedPrice: formatIrtInput(e.target.value) }
: entry,
),
)
}
placeholder={t('storeItems.discount.placeholder')}
disabled={isSubmitting}
/>
</div>
))}
)
})}
</div>
{error && <p className={modalStyles.errorText}>{error}</p>}
@@ -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;
@@ -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')}
</button>
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSaving}>
{t('categories.modal.cancel')}