Files
dashboards/apps/business/src/components/AddVariationModal.tsx
T

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,
)
}