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:
co-authored by
Cursor
parent
7b2ed7c7b0
commit
a412cb82fc
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user