Add custom color picker, move website link to store items, and fix sidebar logo aspect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 15:28:14 +03:30
co-authored by Cursor
parent 592540cc7e
commit 00f2e0f729
27 changed files with 663 additions and 227 deletions
@@ -3,10 +3,10 @@ import { createPortal } from 'react-dom'
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 { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets'
import type { Variation, VariationType } from '../types/variation'
import { SIZE_OPTIONS, normalizeColorNames } from '../types/variation'
import { SIZE_OPTIONS } from '../types/variation'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { Tooltip } from './Tooltip'
import styles from './VariationsModal.module.css'
@@ -18,15 +18,42 @@ interface AddVariationModalProps {
initial?: Variation | null
}
interface SelectedColor {
label: string
hex: string
}
const ANIMATION_MS = 220
const DEFAULT_CUSTOM_HEX = '#64748B'
function seedCustomValues(values: string[]) {
const trimmed = values.map((value) => value.trim()).filter(Boolean)
return trimmed.length ? trimmed : ['']
}
function colorLabelKey(name: string): BusinessMessageKey {
return `categories.variations.color.${name}` as BusinessMessageKey
function normalizeHex(value: string): string {
const trimmed = value.trim()
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toUpperCase()
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toUpperCase()}`
return DEFAULT_CUSTOM_HEX
}
function colorsFromInitial(initial: Variation): SelectedColor[] {
return initial.values
.map((raw, index) => {
const label = raw.trim()
if (!label) return null
const canonical = normalizeColorName(label)
const provided = initial.colorHexes?.[index]
const hex =
(provided && /^#[0-9a-fA-F]{6}$/.test(provided.trim())
? provided.trim().toUpperCase()
: null) ??
getColorHex(canonical) ??
DEFAULT_CUSTOM_HEX
return { label: canonical, hex }
})
.filter((item): item is SelectedColor => item !== null)
}
export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) {
@@ -38,10 +65,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color')
const [customName, setCustomName] = useState('')
const [selectedColors, setSelectedColors] = useState<string[]>([])
const [selectedColors, setSelectedColors] = useState<SelectedColor[]>([])
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
const [customValues, setCustomValues] = useState<string[]>([''])
const [colorQuery, setColorQuery] = useState('')
const [customColorName, setCustomColorName] = useState('')
const [customColorHex, setCustomColorHex] = useState(DEFAULT_CUSTOM_HEX)
const [customColorError, setCustomColorError] = useState('')
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
const pendingFocusIndex = useRef<number | null>(null)
@@ -56,12 +86,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
setMounted(true)
setClosing(false)
setColorQuery('')
setCustomColorName('')
setCustomColorHex(DEFAULT_CUSTOM_HEX)
setCustomColorError('')
if (initial) {
setType(initial.type)
setCustomName(initial.type === 'custom' ? initial.name : '')
setSelectedColors(
initial.type === 'color' ? normalizeColorNames(initial.values) : [],
)
setSelectedColors(initial.type === 'color' ? colorsFromInitial(initial) : [])
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
setCustomValues(
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
@@ -92,26 +123,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
const selectedColorLabels = useMemo(
() => new Set(selectedColors.map((color) => color.label.toLowerCase())),
[selectedColors],
)
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()
const fa = localizeVariationOptionLabel(color.name, t).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((item) => item !== value)
: [...selected, value],
function togglePresetColor(name: string, hex: string) {
setSelectedColors((prev) => {
const exists = prev.some((item) => item.label === name)
if (exists) return prev.filter((item) => item.label !== name)
return [...prev, { label: name, hex }]
})
setCustomColorError('')
}
function removeSelectedColor(label: string) {
setSelectedColors((prev) => prev.filter((item) => item.label !== label))
}
function toggleSize(value: string) {
setSelectedSizes((prev) =>
prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value],
)
}
function addCustomColor() {
const label = customColorName.trim()
if (!label) {
setCustomColorError(t('categories.variations.customColorNameRequired'))
return
}
const hex = normalizeHex(customColorHex)
const key = label.toLowerCase()
const canonical = normalizeColorName(label)
const alreadySelected =
selectedColorLabels.has(key) ||
selectedColorLabels.has(canonical.toLowerCase())
if (alreadySelected) {
setCustomColorError(t('categories.variations.customColorExists'))
return
}
setSelectedColors((prev) => [...prev, { label, hex }])
setCustomColorName('')
setCustomColorHex(DEFAULT_CUSTOM_HEX)
setCustomColorError('')
}
function updateCustomValue(index: number, value: string) {
setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v)))
}
@@ -142,7 +214,12 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
e.preventDefault()
if (type === 'color') {
onSubmit({ name: t('categories.variations.type.color'), type: 'color', values: selectedColors })
onSubmit({
name: t('categories.variations.type.color'),
type: 'color',
values: selectedColors.map((color) => color.label),
colorHexes: selectedColors.map((color) => color.hex),
})
} else if (type === 'size') {
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
} else {
@@ -224,27 +301,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
</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>
)
})}
{selectedColors.map((color) => (
<button
key={color.label}
type="button"
className={`${styles.chip} ${styles.chipSelected}`}
onClick={() => removeSelectedColor(color.label)}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: color.hex }}
aria-hidden="true"
/>
{localizeVariationOptionLabel(color.label, t)}
<X size={12} aria-hidden="true" />
</button>
))}
</div>
) : null}
<div className={styles.chipGrid}>
@@ -256,22 +328,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={color.name}
type="button"
className={`${styles.chip} ${
selectedColors.includes(color.name) ? styles.chipSelected : ''
selectedColorLabels.has(color.name.toLowerCase())
? styles.chipSelected
: ''
}`}
onClick={() =>
toggleOption(color.name, selectedColors, setSelectedColors)
}
onClick={() => togglePresetColor(color.name, color.hex)}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: color.hex }}
aria-hidden="true"
/>
{t(colorLabelKey(color.name))}
{localizeVariationOptionLabel(color.name, t)}
</button>
))
)}
</div>
<div className={styles.customColorBlock}>
<span className={styles.customColorTitle}>
{t('categories.variations.addCustomColor')}
</span>
<div className={styles.customColorRow}>
<input
type="text"
className={styles.customColorNameInput}
value={customColorName}
onChange={(e) => {
setCustomColorName(e.target.value)
if (customColorError) setCustomColorError('')
}}
placeholder={t('categories.variations.customColorNamePlaceholder')}
aria-label={t('categories.variations.customColorName')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCustomColor()
}
}}
/>
<label className={styles.customColorPickerLabel}>
<span className={styles.srOnly}>{t('categories.variations.customColorHex')}</span>
<input
type="color"
className={styles.customColorPicker}
value={customColorHex}
onChange={(e) => setCustomColorHex(normalizeHex(e.target.value))}
/>
</label>
<button
type="button"
className={styles.addValueBtn}
onClick={addCustomColor}
>
<Plus size={14} />
{t('categories.variations.addCustomColorBtn')}
</button>
</div>
{customColorError ? (
<p className={styles.customColorError}>{customColorError}</p>
) : null}
</div>
</div>
)}
@@ -284,7 +401,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={size}
type="button"
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)}
onClick={() => toggleSize(size)}
>
{size}
</button>