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')}
+147
View File
@@ -0,0 +1,147 @@
/** Canonical color presets for category variations (English name stored in API). */
export const COLOR_PRESETS = [
{ name: 'Red', hex: '#EF4444' },
{ name: 'Dark Red', hex: '#B91C1C' },
{ name: 'Crimson', hex: '#DC143C' },
{ name: 'Maroon', hex: '#7F1D1D' },
{ name: 'Rose', hex: '#F43F5E' },
{ name: 'Pink', hex: '#EC4899' },
{ name: 'Hot Pink', hex: '#DB2777' },
{ name: 'Magenta', hex: '#D946EF' },
{ name: 'Coral', hex: '#FB7185' },
{ name: 'Salmon', hex: '#FDA4AF' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Dark Orange', hex: '#C2410C' },
{ name: 'Amber', hex: '#F59E0B' },
{ name: 'Yellow', hex: '#EAB308' },
{ name: 'Mustard', hex: '#CA8A04' },
{ name: 'Gold', hex: '#D4AF37' },
{ name: 'Bronze', hex: '#B45309' },
{ name: 'Copper', hex: '#B87333' },
{ name: 'Brown', hex: '#92400E' },
{ name: 'Dark Brown', hex: '#5C3A21' },
{ name: 'Tan', hex: '#D2B48C' },
{ name: 'Beige', hex: '#D4C4A8' },
{ name: 'Cream', hex: '#FFF7ED' },
{ name: 'Ivory', hex: '#FFFFF0' },
{ name: 'Khaki', hex: '#C3B091' },
{ name: 'Olive', hex: '#808000' },
{ name: 'Lime', hex: '#84CC16' },
{ name: 'Green', hex: '#22C55E' },
{ name: 'Dark Green', hex: '#166534' },
{ name: 'Forest', hex: '#14532D' },
{ name: 'Emerald', hex: '#059669' },
{ name: 'Mint', hex: '#6EE7B7' },
{ name: 'Teal', hex: '#14B8A6' },
{ name: 'Turquoise', hex: '#2DD4BF' },
{ name: 'Cyan', hex: '#06B6D4' },
{ name: 'Sky Blue', hex: '#38BDF8' },
{ name: 'Light Blue', hex: '#93C5FD' },
{ name: 'Blue', hex: '#3B82F6' },
{ name: 'Royal Blue', hex: '#1D4ED8' },
{ name: 'Navy', hex: '#1E3A5F' },
{ name: 'Indigo', hex: '#4F46E5' },
{ name: 'Purple', hex: '#A855F7' },
{ name: 'Violet', hex: '#7C3AED' },
{ name: 'Lavender', hex: '#C4B5FD' },
{ name: 'Lilac', hex: '#D8B4FE' },
{ name: 'Burgundy', hex: '#9F1239' },
{ name: 'Wine', hex: '#881337' },
{ name: 'Gray', hex: '#6B7280' },
{ name: 'Light Gray', hex: '#D1D5DB' },
{ name: 'Dark Gray', hex: '#374151' },
{ name: 'Charcoal', hex: '#1F2937' },
{ name: 'Silver', hex: '#C0C0C0' },
{ name: 'Black', hex: '#111827' },
{ name: 'White', hex: '#FFFFFF' },
] as const
export type ColorPresetName = (typeof COLOR_PRESETS)[number]['name']
export const COLOR_OPTIONS: ColorPresetName[] = COLOR_PRESETS.map((c) => c.name)
/** Legacy / Farsi labels → English preset name (for edit + search). */
export const COLOR_ALIASES: Record<string, ColorPresetName> = {
قرمز: 'Red',
'قرمز تیره': 'Dark Red',
آلبالویی: 'Crimson',
عنابی: 'Maroon',
صورتی: 'Pink',
'صورتی تند': 'Hot Pink',
سرخابی: 'Magenta',
مرجانی: 'Coral',
سالمون: 'Salmon',
نارنجی: 'Orange',
کهربایی: 'Amber',
زرد: 'Yellow',
خردلی: 'Mustard',
طلایی: 'Gold',
طلائی: 'Gold',
برنزی: 'Bronze',
مسی: 'Copper',
قهوهای: 'Brown',
'قهوه ای': 'Brown',
قهوه‌ای: 'Brown',
'قهوه‌ای تیره': 'Dark Brown',
خاکی: 'Tan',
بژ: 'Beige',
کرم: 'Cream',
عاجی: 'Ivory',
زیتونی: 'Olive',
لیمویی: 'Lime',
سبز: 'Green',
'سبز تیره': 'Dark Green',
زمردی: 'Emerald',
نعنایی: 'Mint',
سبزآبی: 'Teal',
فیروزهای: 'Turquoise',
'فیروزه ای': 'Turquoise',
فیروزه‌ای: 'Turquoise',
آبی: 'Blue',
آسمانی: 'Sky Blue',
'آبی روشن': 'Light Blue',
'آبی سلطنتی': 'Royal Blue',
سرمهای: 'Navy',
'سرمه ای': 'Navy',
سرمه‌ای: 'Navy',
نیلی: 'Indigo',
بنفش: 'Purple',
یاسی: 'Lilac',
اسطوخودوسی: 'Lavender',
بادمجانی: 'Burgundy',
خاکستری: 'Gray',
طوسی: 'Gray',
'خاکستری روشن': 'Light Gray',
'خاکستری تیره': 'Dark Gray',
ذغالی: 'Charcoal',
نقرهای: 'Silver',
'نقره ای': 'Silver',
نقره‌ای: 'Silver',
مشکی: 'Black',
سیاه: 'Black',
سفید: 'White',
}
export function getColorHex(name: string): string | null {
return COLOR_PRESETS.find((c) => c.name === name)?.hex ?? null
}
export function normalizeColorName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return trimmed
if ((COLOR_OPTIONS as string[]).includes(trimmed)) return trimmed
const alias = COLOR_ALIASES[trimmed] ?? COLOR_ALIASES[trimmed.replace(/\u200c/g, '')]
return alias ?? trimmed
}
export function normalizeColorNames(values: string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {
const name = normalizeColorName(value)
if (!name || seen.has(name)) continue
seen.add(name)
result.push(name)
}
return result
}
+133 -22
View File
@@ -245,7 +245,7 @@ const en = {
'home.card.finance.link': 'View finance',
'home.chart.orders.title': 'Orders',
'home.chart.orders.subtitle': 'Orders and cart adds in the last 30 days',
'home.chart.orders.subtitle': 'Orders and cart adds in the last 30 days · {from} – {to}',
'home.chart.orders.legend': 'Orders ({count})',
'home.chart.orders.cartLegend': 'Added to basket ({count})',
'home.chart.orders.loading': 'Loading chart...',
@@ -253,7 +253,8 @@ const en = {
'home.chart.orders.bar': '{day}: {count} orders',
'home.chart.orders.cartBar': '{day}: {count} added to basket',
'home.chart.customers.title': 'Users',
'home.chart.customers.subtitle': 'Registrations and active users in the last 30 days',
'home.chart.customers.subtitle':
'Registrations and active users in the last 30 days · {from} – {to}',
'home.chart.customers.legend': 'Registered ({count})',
'home.chart.customers.activeLegend': 'Active ({count})',
'home.chart.customers.loading': 'Loading chart...',
@@ -261,7 +262,8 @@ const en = {
'home.chart.customers.bar': '{day}: {count} registered',
'home.chart.customers.activeBar': '{day}: {count} active',
'home.chart.customersJoined.title': 'Customers joined',
'home.chart.customersJoined.subtitle': 'Registrations and active users in the last year',
'home.chart.customersJoined.subtitle':
'Registrations and active users in the last year · {from} – {to}',
'home.chart.customersJoined.legend': 'Registered ({count})',
'home.chart.customersJoined.activeLegend': 'Active ({count})',
'home.chart.customersJoined.loading': 'Loading chart...',
@@ -269,25 +271,26 @@ const en = {
'home.chart.customersJoined.bar': '{month}: {count} registered',
'home.chart.customersJoined.activeBar': '{month}: {count} active',
'home.chart.blogViews.title': 'Article views',
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days',
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days · {from} – {to}',
'home.chart.blogViews.legend': 'Views ({count})',
'home.chart.blogViews.loading': 'Loading chart...',
'home.chart.blogViews.error': 'Unable to load blog view activity.',
'home.chart.blogViews.bar': '{day}: {count} views',
'home.chart.productViews.title': 'Product views',
'home.chart.productViews.subtitle': 'Product page views in the last 30 days',
'home.chart.productViews.subtitle': 'Product page views in the last 30 days · {from} – {to}',
'home.chart.productViews.legend': 'Views ({count})',
'home.chart.productViews.loading': 'Loading chart...',
'home.chart.productViews.error': 'Unable to load product view activity.',
'home.chart.productViews.bar': '{day}: {count} views',
'home.chart.websiteViews.title': 'Website views',
'home.chart.websiteViews.subtitle': 'Storefront page views in the last 30 days',
'home.chart.websiteViews.subtitle': 'Storefront page views in the last 30 days · {from} – {to}',
'home.chart.websiteViews.legend': 'Views ({count})',
'home.chart.websiteViews.loading': 'Loading chart...',
'home.chart.websiteViews.error': 'Unable to load website view activity.',
'home.chart.websiteViews.bar': '{day}: {count} views',
'home.chart.portfolioViews.title': 'Portfolio views',
'home.chart.portfolioViews.subtitle': 'Portfolio page views in the last 30 days',
'home.chart.portfolioViews.subtitle':
'Portfolio page views in the last 30 days · {from} – {to}',
'home.chart.portfolioViews.legend': 'Views ({count})',
'home.chart.portfolioViews.loading': 'Loading chart...',
'home.chart.portfolioViews.error': 'Unable to load portfolio view activity.',
@@ -582,6 +585,9 @@ const en = {
'products.form.aiRun': 'Run',
'products.form.aiSummaryFilled': 'Summary filled with AI.',
'products.form.aiDescriptionFilled': 'Description filled with AI.',
'products.form.aiTranslateName': 'Translate Persian name to English',
'products.form.aiTranslateNameNeedFa': 'Enter the Persian product name first.',
'products.form.aiNameEnFilled': 'English name filled with AI.',
'products.form.images': 'Product Images',
'products.form.imagesAdd': 'Add images',
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
@@ -645,6 +651,8 @@ const en = {
'categories.variations.type.size': 'Size',
'categories.variations.type.custom': 'Custom',
'categories.variations.selectColors': 'Select colors',
'categories.variations.searchColors': 'Search colors…',
'categories.variations.noColorMatch': 'No colors match your search.',
'categories.variations.selectSizes': 'Select sizes',
'categories.variations.name': 'Variation name',
'categories.variations.namePlaceholder': 'e.g. Guarantee, Material',
@@ -655,13 +663,59 @@ const en = {
'categories.variations.submit': 'Add',
'categories.variations.saveEdit': 'Save changes',
'categories.variations.color.Red': 'Red',
'categories.variations.color.Blue': 'Blue',
'categories.variations.color.Dark Red': 'Dark Red',
'categories.variations.color.Crimson': 'Crimson',
'categories.variations.color.Maroon': 'Maroon',
'categories.variations.color.Rose': 'Rose',
'categories.variations.color.Pink': 'Pink',
'categories.variations.color.Hot Pink': 'Hot Pink',
'categories.variations.color.Magenta': 'Magenta',
'categories.variations.color.Coral': 'Coral',
'categories.variations.color.Salmon': 'Salmon',
'categories.variations.color.Orange': 'Orange',
'categories.variations.color.Dark Orange': 'Dark Orange',
'categories.variations.color.Amber': 'Amber',
'categories.variations.color.Yellow': 'Yellow',
'categories.variations.color.Mustard': 'Mustard',
'categories.variations.color.Gold': 'Gold',
'categories.variations.color.Bronze': 'Bronze',
'categories.variations.color.Copper': 'Copper',
'categories.variations.color.Brown': 'Brown',
'categories.variations.color.Dark Brown': 'Dark Brown',
'categories.variations.color.Tan': 'Tan',
'categories.variations.color.Beige': 'Beige',
'categories.variations.color.Cream': 'Cream',
'categories.variations.color.Ivory': 'Ivory',
'categories.variations.color.Khaki': 'Khaki',
'categories.variations.color.Olive': 'Olive',
'categories.variations.color.Lime': 'Lime',
'categories.variations.color.Green': 'Green',
'categories.variations.color.Dark Green': 'Dark Green',
'categories.variations.color.Forest': 'Forest',
'categories.variations.color.Emerald': 'Emerald',
'categories.variations.color.Mint': 'Mint',
'categories.variations.color.Teal': 'Teal',
'categories.variations.color.Turquoise': 'Turquoise',
'categories.variations.color.Cyan': 'Cyan',
'categories.variations.color.Sky Blue': 'Sky Blue',
'categories.variations.color.Light Blue': 'Light Blue',
'categories.variations.color.Blue': 'Blue',
'categories.variations.color.Royal Blue': 'Royal Blue',
'categories.variations.color.Navy': 'Navy',
'categories.variations.color.Indigo': 'Indigo',
'categories.variations.color.Purple': 'Purple',
'categories.variations.color.Violet': 'Violet',
'categories.variations.color.Lavender': 'Lavender',
'categories.variations.color.Lilac': 'Lilac',
'categories.variations.color.Burgundy': 'Burgundy',
'categories.variations.color.Wine': 'Wine',
'categories.variations.color.Gray': 'Gray',
'categories.variations.color.Light Gray': 'Light Gray',
'categories.variations.color.Dark Gray': 'Dark Gray',
'categories.variations.color.Charcoal': 'Charcoal',
'categories.variations.color.Silver': 'Silver',
'categories.variations.color.Black': 'Black',
'categories.variations.color.White': 'White',
'categories.variations.color.Silver': 'Silver',
'categories.variations.color.Gold': 'Gold',
'categories.variations.color.Navy': 'Navy',
'categories.technical.title': 'Technical Data Form',
'categories.technical.loading': 'Loading form...',
@@ -834,6 +888,7 @@ const en = {
'storeItems.discount.regularPrice': 'Regular price',
'storeItems.discount.discountedPrice': 'Discounted price (IRT)',
'storeItems.discount.placeholder': 'No discount',
'storeItems.discount.remove': 'Remove discount',
'storeItems.discount.save': 'Save discounts',
'storeItems.discount.saving': 'Saving…',
'storeItems.discount.errorSave': 'Unable to save discounts.',
@@ -2191,6 +2246,8 @@ const en = {
'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.',
'login.error.access': 'You do not have access to this business dashboard.',
'login.error.invalidCredentials': 'Invalid mobile number or password.',
'login.error.cellNotVerified':
'Cell number is not verified. Please complete OTP verification.',
'signup.title': 'Create account',
'signup.subtitle': 'Register for {domain}',
@@ -2504,7 +2561,7 @@ const fa: Record<MessageKey, string> = {
'home.card.finance.link': 'مشاهده مالی',
'home.chart.orders.title': 'سفارش‌ها',
'home.chart.orders.subtitle': 'سفارش‌ها در ۳۰ روز گذشته',
'home.chart.orders.subtitle': 'سفارش‌ها در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.orders.legend': 'سفارش ({count})',
'home.chart.orders.cartLegend': 'افزودن به سبد ({count})',
'home.chart.orders.loading': 'در حال بارگذاری نمودار...',
@@ -2512,7 +2569,7 @@ const fa: Record<MessageKey, string> = {
'home.chart.orders.bar': '{day}: {count} سفارش',
'home.chart.orders.cartBar': '{day}: {count} افزودن به سبد',
'home.chart.customers.title': 'کاربران',
'home.chart.customers.subtitle': 'ثبت‌نام و کاربران فعال در ۳۰ روز گذشته',
'home.chart.customers.subtitle': 'ثبت‌نام و کاربران فعال در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.customers.legend': 'ثبت‌نام ({count})',
'home.chart.customers.activeLegend': 'فعال ({count})',
'home.chart.customers.loading': 'در حال بارگذاری نمودار...',
@@ -2520,7 +2577,7 @@ const fa: Record<MessageKey, string> = {
'home.chart.customers.bar': '{day}: {count} ثبت‌نام',
'home.chart.customers.activeBar': '{day}: {count} فعال',
'home.chart.customersJoined.title': 'مشتریان عضو شده',
'home.chart.customersJoined.subtitle': 'مشتریان عضو شده در ۱ سال گذشته',
'home.chart.customersJoined.subtitle': 'مشتریان عضو شده در ۱ سال گذشته از {from} تا {to}',
'home.chart.customersJoined.legend': 'ثبت‌نام ({count})',
'home.chart.customersJoined.activeLegend': 'فعال ({count})',
'home.chart.customersJoined.loading': 'در حال بارگذاری نمودار...',
@@ -2528,25 +2585,25 @@ const fa: Record<MessageKey, string> = {
'home.chart.customersJoined.bar': '{month}: {count} ثبت‌نام',
'home.chart.customersJoined.activeBar': '{month}: {count} فعال',
'home.chart.blogViews.title': 'بازدید مقالات',
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته',
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.blogViews.legend': 'بازدید ({count})',
'home.chart.blogViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.blogViews.error': 'بارگذاری بازدید مقالات ممکن نشد.',
'home.chart.blogViews.bar': '{day}: {count} بازدید',
'home.chart.productViews.title': 'بازدید محصولات',
'home.chart.productViews.subtitle': 'بازدید صفحات محصول در ۳۰ روز گذشته',
'home.chart.productViews.subtitle': 'بازدید صفحات محصول در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.productViews.legend': 'بازدید ({count})',
'home.chart.productViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.productViews.error': 'بارگذاری بازدید محصولات ممکن نشد.',
'home.chart.productViews.bar': '{day}: {count} بازدید',
'home.chart.websiteViews.title': 'بازدید وب‌سایت',
'home.chart.websiteViews.subtitle': 'بازدید صفحات فروشگاه در ۳۰ روز گذشته',
'home.chart.websiteViews.subtitle': 'بازدید صفحات فروشگاه در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.websiteViews.legend': 'بازدید ({count})',
'home.chart.websiteViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.websiteViews.error': 'بارگذاری بازدید وب‌سایت ممکن نشد.',
'home.chart.websiteViews.bar': '{day}: {count} بازدید',
'home.chart.portfolioViews.title': 'بازدید نمونه‌کارها',
'home.chart.portfolioViews.subtitle': 'بازدید صفحات نمونه‌کار در ۳۰ روز گذشته',
'home.chart.portfolioViews.subtitle': 'بازدید صفحات نمونه‌کار در ۳۰ روز گذشته از {from} تا {to}',
'home.chart.portfolioViews.legend': 'بازدید ({count})',
'home.chart.portfolioViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.portfolioViews.error': 'بارگذاری بازدید نمونه‌کارها ممکن نشد.',
@@ -2839,6 +2896,9 @@ const fa: Record<MessageKey, string> = {
'products.form.aiRun': 'اجرا',
'products.form.aiSummaryFilled': 'خلاصه با هوش مصنوعی پر شد.',
'products.form.aiDescriptionFilled': 'توضیحات با هوش مصنوعی پر شد.',
'products.form.aiTranslateName': 'ترجمه نام فارسی به انگلیسی',
'products.form.aiTranslateNameNeedFa': 'ابتدا نام فارسی محصول را وارد کنید.',
'products.form.aiNameEnFilled': 'نام انگلیسی با هوش مصنوعی پر شد.',
'products.form.images': 'تصاویر محصول',
'products.form.imagesAdd': 'افزودن تصاویر',
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
@@ -2902,6 +2962,8 @@ const fa: Record<MessageKey, string> = {
'categories.variations.type.size': 'سایز',
'categories.variations.type.custom': 'دلخواه',
'categories.variations.selectColors': 'انتخاب رنگ‌ها',
'categories.variations.searchColors': 'جستجوی رنگ…',
'categories.variations.noColorMatch': 'رنگی با این جستجو پیدا نشد.',
'categories.variations.selectSizes': 'انتخاب سایزها',
'categories.variations.name': 'نام تنوع',
'categories.variations.namePlaceholder': 'مثلاً گارانتی، جنس',
@@ -2912,13 +2974,59 @@ const fa: Record<MessageKey, string> = {
'categories.variations.submit': 'افزودن',
'categories.variations.saveEdit': 'ذخیره تغییرات',
'categories.variations.color.Red': 'قرمز',
'categories.variations.color.Blue': 'آبی',
'categories.variations.color.Dark Red': 'قرمز تیره',
'categories.variations.color.Crimson': 'آلبالویی',
'categories.variations.color.Maroon': 'عنابی',
'categories.variations.color.Rose': 'رز',
'categories.variations.color.Pink': 'صورتی',
'categories.variations.color.Hot Pink': 'صورتی تند',
'categories.variations.color.Magenta': 'سرخابی',
'categories.variations.color.Coral': 'مرجانی',
'categories.variations.color.Salmon': 'سالمون',
'categories.variations.color.Orange': 'نارنجی',
'categories.variations.color.Dark Orange': 'نارنجی تیره',
'categories.variations.color.Amber': 'کهربایی',
'categories.variations.color.Yellow': 'زرد',
'categories.variations.color.Mustard': 'خردلی',
'categories.variations.color.Gold': 'طلایی',
'categories.variations.color.Bronze': 'برنزی',
'categories.variations.color.Copper': 'مسی',
'categories.variations.color.Brown': 'قهوه‌ای',
'categories.variations.color.Dark Brown': 'قهوه‌ای تیره',
'categories.variations.color.Tan': 'خاکی',
'categories.variations.color.Beige': 'بژ',
'categories.variations.color.Cream': 'کرم',
'categories.variations.color.Ivory': 'عاجی',
'categories.variations.color.Khaki': 'خاکی روشن',
'categories.variations.color.Olive': 'زیتونی',
'categories.variations.color.Lime': 'لیمویی',
'categories.variations.color.Green': 'سبز',
'categories.variations.color.Dark Green': 'سبز تیره',
'categories.variations.color.Forest': 'سبز جنگلی',
'categories.variations.color.Emerald': 'زمردی',
'categories.variations.color.Mint': 'نعنایی',
'categories.variations.color.Teal': 'سبزآبی',
'categories.variations.color.Turquoise': 'فیروزه‌ای',
'categories.variations.color.Cyan': 'فیروزه روشن',
'categories.variations.color.Sky Blue': 'آسمانی',
'categories.variations.color.Light Blue': 'آبی روشن',
'categories.variations.color.Blue': 'آبی',
'categories.variations.color.Royal Blue': 'آبی سلطنتی',
'categories.variations.color.Navy': 'سرمه‌ای',
'categories.variations.color.Indigo': 'نیلی',
'categories.variations.color.Purple': 'بنفش',
'categories.variations.color.Violet': 'بنفش تیره',
'categories.variations.color.Lavender': 'اسطوخودوسی',
'categories.variations.color.Lilac': 'یاسی',
'categories.variations.color.Burgundy': 'بادمجانی',
'categories.variations.color.Wine': 'شرابی',
'categories.variations.color.Gray': 'خاکستری',
'categories.variations.color.Light Gray': 'خاکستری روشن',
'categories.variations.color.Dark Gray': 'خاکستری تیره',
'categories.variations.color.Charcoal': 'ذغالی',
'categories.variations.color.Silver': 'نقره‌ای',
'categories.variations.color.Black': 'مشکی',
'categories.variations.color.White': 'سفید',
'categories.variations.color.Silver': 'نقره‌ای',
'categories.variations.color.Gold': 'طلایی',
'categories.variations.color.Navy': 'سرمه‌ای',
'categories.technical.title': 'فرم اطلاعات فنی',
'categories.technical.loading': 'در حال بارگذاری فرم...',
@@ -3091,6 +3199,7 @@ const fa: Record<MessageKey, string> = {
'storeItems.discount.regularPrice': 'قیمت عادی',
'storeItems.discount.discountedPrice': 'قیمت با تخفیف (IRT)',
'storeItems.discount.placeholder': 'بدون تخفیف',
'storeItems.discount.remove': 'حذف تخفیف',
'storeItems.discount.save': 'ذخیره تخفیف',
'storeItems.discount.saving': 'در حال ذخیره…',
'storeItems.discount.errorSave': 'ذخیره تخفیف ممکن نشد.',
@@ -4450,6 +4559,8 @@ const fa: Record<MessageKey, string> = {
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.',
'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.',
'login.error.cellNotVerified':
'شماره موبایل تأیید نشده است. لطفاً تأیید پیامکی (OTP) را تکمیل کنید.',
'signup.title': 'ایجاد حساب',
'signup.subtitle': 'ثبت‌نام برای {domain}',
@@ -106,6 +106,18 @@
z-index: 2;
}
.aiFillAnchorInline {
position: absolute;
top: 50%;
inset-inline-end: 6px;
transform: translateY(-50%);
z-index: 2;
}
.nameEnField input {
padding-inline-end: 44px;
}
.aiFillBtn {
width: 30px;
height: 30px;
+53 -14
View File
@@ -26,7 +26,6 @@ import {
} from '../services/productService'
import {
fillProductFieldByAi,
type ProductAiFillField,
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
@@ -58,7 +57,7 @@ export function AddNewProductPage() {
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [aiField, setAiField] = useState<ProductAiFillField | null>(null)
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false)
const categoryOptions = flattenCategories(categories)
@@ -69,8 +68,9 @@ export function AddNewProductPage() {
}, [categoryOptions, categoryId])
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
function openAiFill(field: ProductAiFillField) {
function openAiFill(field: 'summary' | 'description') {
if (!nameFa.trim() && !nameEn.trim()) {
setError(t('products.form.aiFillNeedName'))
return
@@ -103,6 +103,30 @@ export function AddNewProductPage() {
}
}
async function translateNameEnByAi() {
if (!nameFa.trim()) {
setError(t('products.form.aiTranslateNameNeedFa'))
return
}
setError('')
setIsAiRunning(true)
try {
const result = await fillProductFieldByAi({
field: 'nameEn',
nameFa: nameFa.trim(),
categoryId: categoryId || undefined,
})
if (result.field === 'nameEn') {
setNameEn(result.text)
showToast(t('products.form.aiNameEnFilled'), 'success')
}
} catch (err) {
setError(err instanceof ApiError ? err.message : t('products.form.aiFillError'))
} finally {
setIsAiRunning(false)
}
}
useEffect(() => {
const controller = new AbortController()
@@ -302,17 +326,32 @@ export function AddNewProductPage() {
<div className={`${styles.field} ${styles.fieldHalf}`}>
<label htmlFor="nameEn">{t('products.form.nameEn')}</label>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder={t('products.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting}
/>
<div className={`${styles.fieldWithAi} ${styles.nameEnField}`}>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder={t('products.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting || isAiRunning}
/>
<span className={styles.aiFillAnchorInline}>
<Tooltip label={t('products.form.aiTranslateName')}>
<button
type="button"
className={`${aiStyles.aiBtn} ${styles.aiFillBtn}`}
onClick={() => void translateNameEnByAi()}
disabled={!canTranslateNameEn}
aria-label={t('products.form.aiTranslateName')}
>
<Sparkles size={14} />
</button>
</Tooltip>
</span>
</div>
</div>
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
+9 -6
View File
@@ -60,6 +60,7 @@ export function CategoriesPage() {
const [categoryVariations, setCategoryVariations] = useState<Record<string, Variation[]>>({})
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,
+5
View File
@@ -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)
}
@@ -42,6 +42,7 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal)
export type WebsiteViewKind =
| 'home'
| 'all'
| 'portfolio_list'
| 'portfolio_detail'
| 'product_list'
@@ -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<ProductAiFillField, 'summary' | 'description'>
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,
+9 -1
View File
@@ -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']
+17
View File
@@ -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',
})
}