477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Plus, Search, X } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { useT } from '../i18n/useT'
|
|
import { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets'
|
|
import type { Variation, VariationType } from '../types/variation'
|
|
import { SIZE_OPTIONS } from '../types/variation'
|
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
|
import { Tooltip } from './Tooltip'
|
|
import styles from './VariationsModal.module.css'
|
|
|
|
interface AddVariationModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
onSubmit: (variation: Omit<Variation, 'id'>) => void
|
|
/** When set, form opens in edit mode with values pre-filled. */
|
|
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 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) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const isFa = locale === 'fa'
|
|
const isEdit = Boolean(initial)
|
|
const [mounted, setMounted] = useState(open)
|
|
const [closing, setClosing] = useState(false)
|
|
const [type, setType] = useState<VariationType>('color')
|
|
const [customName, setCustomName] = useState('')
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (pendingFocusIndex.current === null) return
|
|
valueInputRefs.current[pendingFocusIndex.current]?.focus()
|
|
pendingFocusIndex.current = null
|
|
}, [customValues.length])
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
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' ? colorsFromInitial(initial) : [])
|
|
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
|
|
setCustomValues(
|
|
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
|
|
)
|
|
} else {
|
|
setType('color')
|
|
setCustomName('')
|
|
setSelectedColors([])
|
|
setSelectedSizes([])
|
|
setCustomValues([''])
|
|
}
|
|
} else if (mounted) {
|
|
setClosing(true)
|
|
const timer = setTimeout(() => {
|
|
setMounted(false)
|
|
setClosing(false)
|
|
}, ANIMATION_MS)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [open, mounted, initial])
|
|
|
|
useEffect(() => {
|
|
if (!mounted || closing) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
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 = localizeVariationOptionLabel(color.name, t).toLowerCase()
|
|
return en.includes(q) || fa.includes(q)
|
|
})
|
|
}, [colorQuery, t])
|
|
|
|
if (!mounted) return null
|
|
|
|
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)))
|
|
}
|
|
|
|
function addCustomRow() {
|
|
setCustomValues((prev) => [...prev, ''])
|
|
}
|
|
|
|
function removeCustomRow(index: number) {
|
|
setCustomValues((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
function handleValueKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key !== 'Enter') return
|
|
e.preventDefault()
|
|
|
|
const nextIndex = index + 1
|
|
if (nextIndex < customValues.length) {
|
|
valueInputRefs.current[nextIndex]?.focus()
|
|
return
|
|
}
|
|
|
|
pendingFocusIndex.current = nextIndex
|
|
setCustomValues((prev) => [...prev, ''])
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
|
|
if (type === 'color') {
|
|
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 {
|
|
const name = customName.trim() || t('categories.variations.type.custom')
|
|
const values = customValues.map((v) => v.trim()).filter(Boolean)
|
|
onSubmit({ name, type: 'custom', values })
|
|
}
|
|
}
|
|
|
|
const canSubmit =
|
|
type === 'color'
|
|
? selectedColors.length > 0
|
|
: type === 'size'
|
|
? selectedSizes.length > 0
|
|
: customValues.some((v) => v.trim())
|
|
|
|
return createPortal(
|
|
<div
|
|
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className={`${styles.modal} ${styles.modalFormWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="add-variation-title"
|
|
lang={isFa ? 'fa' : 'en'}
|
|
dir={isFa ? 'rtl' : 'ltr'}
|
|
>
|
|
<div className={styles.header}>
|
|
<h3 id="add-variation-title" className={styles.title}>
|
|
{isEdit ? t('categories.variations.editTitle') : t('categories.variations.addTitle')}
|
|
</h3>
|
|
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form className={styles.body} onSubmit={handleSubmit}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="variationType">{t('categories.variations.type')}</label>
|
|
<select
|
|
id="variationType"
|
|
value={type}
|
|
disabled={isEdit}
|
|
onChange={(e) => setType(e.target.value as VariationType)}
|
|
>
|
|
<option value="color">{t('categories.variations.type.color')}</option>
|
|
<option value="size">{t('categories.variations.type.size')}</option>
|
|
<option value="custom">{t('categories.variations.type.custom')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{type === 'color' && (
|
|
<div className={styles.field}>
|
|
<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
|
|
type="button"
|
|
className={styles.colorSearchClear}
|
|
onClick={() => setColorQuery('')}
|
|
aria-label={t('common.close')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{selectedColors.length > 0 ? (
|
|
<div className={styles.selectedColors}>
|
|
{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}>
|
|
{filteredColors.length === 0 ? (
|
|
<p className={styles.colorEmpty}>{t('categories.variations.noColorMatch')}</p>
|
|
) : (
|
|
filteredColors.map((color) => (
|
|
<button
|
|
key={color.name}
|
|
type="button"
|
|
className={`${styles.chip} ${
|
|
selectedColorLabels.has(color.name.toLowerCase())
|
|
? styles.chipSelected
|
|
: ''
|
|
}`}
|
|
onClick={() => togglePresetColor(color.name, color.hex)}
|
|
>
|
|
<span
|
|
className={styles.colorSwatch}
|
|
style={{ backgroundColor: color.hex }}
|
|
aria-hidden="true"
|
|
/>
|
|
{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>
|
|
)}
|
|
|
|
{type === 'size' && (
|
|
<div className={styles.field}>
|
|
<label>{t('categories.variations.selectSizes')}</label>
|
|
<div className={styles.chipGrid}>
|
|
{SIZE_OPTIONS.map((size) => (
|
|
<button
|
|
key={size}
|
|
type="button"
|
|
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
|
|
onClick={() => toggleSize(size)}
|
|
>
|
|
{size}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{type === 'custom' && (
|
|
<>
|
|
<div className={styles.field}>
|
|
<label htmlFor="customName">{t('categories.variations.name')}</label>
|
|
<input
|
|
id="customName"
|
|
type="text"
|
|
placeholder={t('categories.variations.namePlaceholder')}
|
|
value={customName}
|
|
onChange={(e) => setCustomName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label>{t('categories.variations.values')}</label>
|
|
<div className={styles.customValues}>
|
|
{customValues.map((value, index) => (
|
|
<div key={index} className={styles.customRow}>
|
|
<input
|
|
ref={(el) => {
|
|
valueInputRefs.current[index] = el
|
|
}}
|
|
type="text"
|
|
placeholder={t('categories.variations.valueN', { n: index + 1 })}
|
|
value={value}
|
|
onChange={(e) => updateCustomValue(index, e.target.value)}
|
|
onKeyDown={(e) => handleValueKeyDown(index, e)}
|
|
/>
|
|
{customValues.length > 1 && (
|
|
<Tooltip label={t('categories.variations.removeValue')}>
|
|
<button
|
|
type="button"
|
|
className={styles.removeValueBtn}
|
|
onClick={() => removeCustomRow(index)}
|
|
aria-label={t('categories.variations.removeValue')}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button type="button" className={styles.addValueBtn} onClick={addCustomRow}>
|
|
<Plus size={14} />
|
|
{t('categories.variations.addValue')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className={styles.actions}>
|
|
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
|
{t('categories.modal.cancel')}
|
|
</button>
|
|
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
|
|
{isEdit ? t('categories.variations.saveEdit') : t('categories.variations.submit')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|