Add customer and business user-product flows across dashboards.
Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -32,21 +32,20 @@
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(148, 163, 184, 0.12) 0%,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './CategoryAiPromptModal.module.css'
|
||||
|
||||
export const DEFAULT_CATEGORY_AI_PROMPT = `Create a practical product category tree for my store.
|
||||
export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
|
||||
if (locale === 'fa') {
|
||||
return `یک درخت دستهبندی محصول کاربردی برای فروشگاه من بساز.
|
||||
|
||||
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن.
|
||||
۳ تا ۵ دسته اصلی با زیردستههای مرتبط بساز تا مرور برای خریداران راحتتر شود.`
|
||||
}
|
||||
|
||||
return `Create a practical product category tree for my store.
|
||||
|
||||
Include English names, Farsi names in Persian script, and short English descriptions.
|
||||
Use 3-5 main categories with relevant subcategories where it helps shoppers browse.`
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORY_AI_PROMPT = buildCategoryAiPrompt('en')
|
||||
|
||||
interface CategoryAiPromptModalProps {
|
||||
open: boolean
|
||||
@@ -26,16 +39,19 @@ export function CategoryAiPromptModal({
|
||||
onRun,
|
||||
isRunning,
|
||||
}: CategoryAiPromptModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [prompt, setPrompt] = useState(DEFAULT_CATEGORY_AI_PROMPT)
|
||||
const [prompt, setPrompt] = useState(() => buildCategoryAiPrompt(isFa ? 'fa' : 'en'))
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setPrompt(DEFAULT_CATEGORY_AI_PROMPT)
|
||||
setPrompt(buildCategoryAiPrompt(isFa ? 'fa' : 'en'))
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -45,7 +61,7 @@ export function CategoryAiPromptModal({
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
}, [open, mounted, isFa])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
@@ -60,7 +76,7 @@ export function CategoryAiPromptModal({
|
||||
e.preventDefault()
|
||||
const trimmed = prompt.trim()
|
||||
if (trimmed.length < 10) {
|
||||
setError('Prompt must be at least 10 characters.')
|
||||
setError(t('products.form.aiPromptTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +89,7 @@ export function CategoryAiPromptModal({
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to generate categories with AI.')
|
||||
setError(t('categories.ai.error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,22 +107,22 @@ export function CategoryAiPromptModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="category-ai-prompt-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h2 id="category-ai-prompt-title" className={modalStyles.title}>
|
||||
Fill Categories with AI
|
||||
{t('categories.ai.title')}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Edit the prompt below, then run it to generate and save a category tree.
|
||||
</p>
|
||||
<p className={styles.subtitle}>{t('categories.ai.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -114,7 +130,7 @@ export function CategoryAiPromptModal({
|
||||
|
||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||
<label htmlFor="category-ai-prompt">Prompt</label>
|
||||
<label htmlFor="category-ai-prompt">{t('products.form.aiPrompt')}</label>
|
||||
<textarea
|
||||
id="category-ai-prompt"
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
@@ -123,6 +139,8 @@ export function CategoryAiPromptModal({
|
||||
rows={8}
|
||||
disabled={isRunning}
|
||||
required
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,30 +153,20 @@ export function CategoryAiPromptModal({
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Cancel
|
||||
{t('categories.modal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={aiStyles.aiBtn}
|
||||
disabled={isRunning || prompt.trim().length < 10}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
Run
|
||||
</>
|
||||
)}
|
||||
<Sparkles size={16} />
|
||||
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Stack above VariationsModal / TechnicalFormModal (z-index 1000–1010) */
|
||||
.overlayStacked {
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 var(--field-padding-x);
|
||||
background-color: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrapOpen,
|
||||
.inputWrap:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.inputWrapDisabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--field-padding-y) 0;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline: 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: var(--elevated-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
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-start: 2px solid rgba(var(--primary-rgb) / 0.35);
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
background: rgba(var(--primary-rgb) / 0.03);
|
||||
padding-inline-start: 14px;
|
||||
}
|
||||
|
||||
.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-start-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optionSecondary {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { UserProductCategoryOption } from '../services/userProductsService'
|
||||
import styles from './CategorySearchSelect.module.css'
|
||||
|
||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface CategorySearchSelectProps {
|
||||
options: UserProductCategoryOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
||||
if (isFa) {
|
||||
return category.nameFa?.trim() || category.name
|
||||
}
|
||||
return category.name || category.nameFa?.trim() || ''
|
||||
}
|
||||
|
||||
export function flattenCategoryTree(
|
||||
categories: UserProductCategoryOption[],
|
||||
): FlatCategoryOption[] {
|
||||
const byParent = new Map<string | null, UserProductCategoryOption[]>()
|
||||
|
||||
for (const category of categories) {
|
||||
const parentKey = category.parentId
|
||||
const list = byParent.get(parentKey) ?? []
|
||||
list.push(category)
|
||||
byParent.set(parentKey, list)
|
||||
}
|
||||
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
||||
return aLabel
|
||||
})
|
||||
}
|
||||
|
||||
const result: FlatCategoryOption[] = []
|
||||
const ids = new Set(categories.map((item) => item.id))
|
||||
|
||||
function walk(parentId: string | null, depth: number) {
|
||||
const children = byParent.get(parentId) ?? []
|
||||
for (const child of children) {
|
||||
result.push({ ...child, depth })
|
||||
walk(child.id, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
|
||||
// Orphans whose parent is missing from the list
|
||||
for (const category of categories) {
|
||||
if (category.parentId && !ids.has(category.parentId)) {
|
||||
if (!result.some((item) => item.id === category.id)) {
|
||||
result.push({ ...category, depth: 0 })
|
||||
walk(category.id, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function filterCategoryTree(
|
||||
flat: FlatCategoryOption[],
|
||||
query: string,
|
||||
): FlatCategoryOption[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return flat
|
||||
|
||||
const byId = new Map(flat.map((item) => [item.id, item]))
|
||||
const matchedIds = new Set<string>()
|
||||
|
||||
for (const item of flat) {
|
||||
const nameEn = item.name.toLowerCase()
|
||||
const nameFa = (item.nameFa || '').toLowerCase()
|
||||
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
|
||||
matchedIds.add(item.id)
|
||||
let parentId = item.parentId
|
||||
while (parentId) {
|
||||
matchedIds.add(parentId)
|
||||
parentId = byId.get(parentId)?.parentId ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flat.filter((item) => matchedIds.has(item.id))
|
||||
}
|
||||
|
||||
export function CategorySearchSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
id,
|
||||
}: CategorySearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const flat = useMemo(() => flattenCategoryTree(options), [options])
|
||||
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
|
||||
|
||||
const selected = flat.find((option) => option.id === value)
|
||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCategory')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
function selectOption(nextId: string) {
|
||||
onChange(nextId)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.wrapper}
|
||||
ref={containerRef}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
styles.inputWrap,
|
||||
open ? styles.inputWrapOpen : '',
|
||||
disabled ? styles.inputWrapDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) setOpen(true)
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{value && !open && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('customerProducts.form.fields.clearCategory')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.option,
|
||||
option.depth > 0 ? styles.optionChild : '',
|
||||
value === option.id ? styles.optionSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
option.depth > 0
|
||||
? {
|
||||
marginInlineStart: `${option.depth * 22}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={() => selectOption(option.id)}
|
||||
role="option"
|
||||
aria-selected={value === option.id}
|
||||
>
|
||||
<span className={styles.optionLabel}>
|
||||
{categoryLabel(option, isFa)}
|
||||
</span>
|
||||
{!isFa && option.nameFa ? (
|
||||
<span className={styles.optionSecondary}>{option.nameFa}</span>
|
||||
) : null}
|
||||
{isFa && option.name && option.name !== option.nameFa ? (
|
||||
<span className={styles.optionSecondary} dir="ltr">
|
||||
{option.name}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 var(--field-padding-x);
|
||||
background-color: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrapOpen,
|
||||
.inputWrap:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.inputWrapDisabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--field-padding-y) 0;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: var(--elevated-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
z-index: 20;
|
||||
list-style: none;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import {
|
||||
getLocationOptionLabel,
|
||||
useLocale,
|
||||
type CityOption,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './CitySearchSelect.module.css'
|
||||
|
||||
interface CitySearchSelectProps {
|
||||
options: CityOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function CitySearchSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
id,
|
||||
}: CitySearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selected = options.find((option) => option.id === value)
|
||||
const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : ''
|
||||
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCity')
|
||||
|
||||
const filtered = options.filter((option) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return (
|
||||
option.nameEn.toLowerCase().includes(q) ||
|
||||
option.nameFa.includes(query.trim()) ||
|
||||
option.slug.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
function selectOption(nextId: string) {
|
||||
onChange(nextId)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef}>
|
||||
<div
|
||||
className={[
|
||||
styles.inputWrap,
|
||||
open ? styles.inputWrapOpen : '',
|
||||
disabled ? styles.inputWrapDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) setOpen(true)
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{value && !open && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('customerProducts.form.fields.clearCity')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('customerProducts.form.fields.noCities')}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === option.id ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption(option.id)}
|
||||
role="option"
|
||||
aria-selected={value === option.id}
|
||||
>
|
||||
{getLocationOptionLabel(option, locale)}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Pencil,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
@@ -129,6 +130,14 @@ export function Sidebar() {
|
||||
],
|
||||
},
|
||||
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
||||
{
|
||||
type: 'link',
|
||||
id: 'customer-products',
|
||||
icon: Package,
|
||||
labelKey: 'nav.customerProducts',
|
||||
to: '/customer-products',
|
||||
moduleId: 'customer_products',
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'blog',
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './CategoryAiPromptModal.module.css'
|
||||
|
||||
export function buildTechnicalFormAiPrompt(categoryName: string) {
|
||||
export function buildTechnicalFormAiPrompt(
|
||||
categoryName: string,
|
||||
locale: 'fa' | 'en' = 'en',
|
||||
) {
|
||||
if (locale === 'fa') {
|
||||
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
||||
|
||||
برای هر فیلد این موارد را مشخص کن:
|
||||
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color)
|
||||
- نوع ورودی: text، textarea، select یا multi_select
|
||||
- الزامی بودن یا نبودن
|
||||
- برای select / multi_select: فهرست گزینهها
|
||||
|
||||
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.`
|
||||
}
|
||||
|
||||
return `Suggest technical data form fields for the "${categoryName}" product category.
|
||||
|
||||
For each field include:
|
||||
@@ -34,16 +51,21 @@ export function TechnicalFormAiPromptModal({
|
||||
onRun,
|
||||
isRunning,
|
||||
}: TechnicalFormAiPromptModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
|
||||
)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setPrompt(buildTechnicalFormAiPrompt(categoryName))
|
||||
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -53,7 +75,7 @@ export function TechnicalFormAiPromptModal({
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted, categoryName])
|
||||
}, [open, mounted, categoryName, isFa])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
@@ -68,7 +90,7 @@ export function TechnicalFormAiPromptModal({
|
||||
e.preventDefault()
|
||||
const trimmed = prompt.trim()
|
||||
if (trimmed.length < 10) {
|
||||
setError('Prompt must be at least 10 characters.')
|
||||
setError(t('products.form.aiPromptTooShort'))
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
@@ -78,7 +100,7 @@ export function TechnicalFormAiPromptModal({
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to generate technical form with AI.')
|
||||
setError(t('categories.technical.ai.error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +109,7 @@ export function TechnicalFormAiPromptModal({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -96,22 +118,22 @@ export function TechnicalFormAiPromptModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tech-form-ai-prompt-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
|
||||
Generate Technical Form with AI
|
||||
{t('categories.technical.ai.title')}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Edit the prompt below, then run it to fill the form fields.
|
||||
</p>
|
||||
<p className={styles.subtitle}>{t('categories.technical.ai.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -119,7 +141,7 @@ export function TechnicalFormAiPromptModal({
|
||||
|
||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||
<label htmlFor="tech-form-ai-prompt">Prompt</label>
|
||||
<label htmlFor="tech-form-ai-prompt">{t('products.form.aiPrompt')}</label>
|
||||
<textarea
|
||||
id="tech-form-ai-prompt"
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
@@ -128,10 +150,12 @@ export function TechnicalFormAiPromptModal({
|
||||
rows={8}
|
||||
disabled={isRunning}
|
||||
required
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<button
|
||||
@@ -140,7 +164,7 @@ export function TechnicalFormAiPromptModal({
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Cancel
|
||||
{t('categories.modal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -148,13 +172,12 @@ export function TechnicalFormAiPromptModal({
|
||||
disabled={isRunning || prompt.trim().length < 10}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{isRunning ? 'Generating…' : 'Run'}
|
||||
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,5 +156,23 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actionsRow {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
/* Physical layout: Save leftmost, Cancel next, AI on the right */
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.actionsMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actionsAi {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { createId } from '../utils/id'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './VariationsModal.module.css'
|
||||
import fieldStyles from './TechnicalFormModal.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
@@ -14,6 +18,7 @@ interface TechnicalFormModalProps {
|
||||
isLoading?: boolean
|
||||
isSaving?: boolean
|
||||
isGenerating?: boolean
|
||||
escapeDisabled?: boolean
|
||||
error?: string
|
||||
onClose: () => void
|
||||
onChange: (fields: TechnicalFormFieldDraft[]) => void
|
||||
@@ -24,11 +29,11 @@ interface TechnicalFormModalProps {
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<TechnicalFieldType, string> = {
|
||||
text: 'Text',
|
||||
textarea: 'Textarea',
|
||||
select: 'Select',
|
||||
multi_select: 'Multi',
|
||||
const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
|
||||
text: 'categories.technical.type.text',
|
||||
textarea: 'categories.technical.type.textarea',
|
||||
select: 'categories.technical.type.select',
|
||||
multi_select: 'categories.technical.type.multi_select',
|
||||
}
|
||||
|
||||
function createEmptyField(): TechnicalFormFieldDraft {
|
||||
@@ -48,6 +53,7 @@ export function TechnicalFormModal({
|
||||
isLoading = false,
|
||||
isSaving = false,
|
||||
isGenerating = false,
|
||||
escapeDisabled = false,
|
||||
error = '',
|
||||
onClose,
|
||||
onChange,
|
||||
@@ -55,6 +61,9 @@ export function TechnicalFormModal({
|
||||
onGenerate,
|
||||
onGenerateClick,
|
||||
}: TechnicalFormModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const optionInputRefs = useRef<Record<string, (HTMLInputElement | null)[]>>({})
|
||||
@@ -82,13 +91,13 @@ export function TechnicalFormModal({
|
||||
}, [fields])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
if (!mounted || closing || escapeDisabled) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose])
|
||||
}, [mounted, closing, escapeDisabled, onClose])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
@@ -180,22 +189,29 @@ export function TechnicalFormModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="technical-form-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 id="technical-form-title" className={styles.title}>
|
||||
Technical Data Form
|
||||
{t('categories.technical.title')}
|
||||
</h3>
|
||||
{categoryName && <p className={styles.subtitle}>{categoryName}</p>}
|
||||
{categoryName ? <p className={styles.subtitle}>{categoryName}</p> : null}
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
type="button"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{isLoading ? (
|
||||
<p className={styles.emptyText}>Loading form...</p>
|
||||
<p className={styles.emptyText}>{t('categories.technical.loading')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={fieldStyles.fieldList}>
|
||||
@@ -206,16 +222,16 @@ export function TechnicalFormModal({
|
||||
id={`label-${field.id}`}
|
||||
type="text"
|
||||
className={fieldStyles.compactInput}
|
||||
placeholder="Field label"
|
||||
placeholder={t('categories.technical.fieldLabel')}
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||
aria-label="Field label"
|
||||
aria-label={t('categories.technical.fieldLabel')}
|
||||
/>
|
||||
<select
|
||||
id={`type-${field.id}`}
|
||||
className={fieldStyles.compactInput}
|
||||
value={field.type}
|
||||
aria-label="Input type"
|
||||
aria-label={t('categories.technical.inputType')}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value as TechnicalFieldType
|
||||
updateField(field.id, {
|
||||
@@ -229,13 +245,16 @@ export function TechnicalFormModal({
|
||||
})
|
||||
}}
|
||||
>
|
||||
{Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => (
|
||||
{(Object.keys(FIELD_TYPE_KEYS) as TechnicalFieldType[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
{t(FIELD_TYPE_KEYS[value])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={fieldStyles.requiredCell} title="Required">
|
||||
<label
|
||||
className={fieldStyles.requiredCell}
|
||||
title={t('categories.technical.required')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isRequired}
|
||||
@@ -243,23 +262,27 @@ export function TechnicalFormModal({
|
||||
updateField(field.id, { isRequired: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>Required</span>
|
||||
<span>{t('categories.technical.required')}</span>
|
||||
</label>
|
||||
<div className={fieldStyles.removeCell}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeRowBtn}
|
||||
onClick={() => removeField(field.id)}
|
||||
aria-label="Remove field"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
<Tooltip label={t('categories.technical.removeField')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeRowBtn}
|
||||
onClick={() => removeField(field.id)}
|
||||
aria-label={t('categories.technical.removeField')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(field.type === 'select' || field.type === 'multi_select') && (
|
||||
<div className={fieldStyles.optionsBlock}>
|
||||
<span className={fieldStyles.optionsLabel}>Options</span>
|
||||
<span className={fieldStyles.optionsLabel}>
|
||||
{t('categories.technical.options')}
|
||||
</span>
|
||||
<div className={fieldStyles.customValues}>
|
||||
{field.options.map((option, optionIndex) => (
|
||||
<div key={optionIndex} className={fieldStyles.customRow}>
|
||||
@@ -271,7 +294,9 @@ export function TechnicalFormModal({
|
||||
optionInputRefs.current[field.id][optionIndex] = el
|
||||
}}
|
||||
type="text"
|
||||
placeholder={`Option ${optionIndex + 1}`}
|
||||
placeholder={t('categories.technical.optionN', {
|
||||
n: optionIndex + 1,
|
||||
})}
|
||||
value={option}
|
||||
onChange={(e) =>
|
||||
updateOption(field.id, optionIndex, e.target.value)
|
||||
@@ -282,14 +307,16 @@ export function TechnicalFormModal({
|
||||
/>
|
||||
{field.options.length > 1 && (
|
||||
<div className={fieldStyles.removeCell}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeRowBtn}
|
||||
onClick={() => removeOption(field.id, optionIndex)}
|
||||
aria-label="Remove option"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<Tooltip label={t('categories.technical.removeOption')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeRowBtn}
|
||||
onClick={() => removeOption(field.id, optionIndex)}
|
||||
aria-label={t('categories.technical.removeOption')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{field.options.length <= 1 && <div />}
|
||||
@@ -301,7 +328,7 @@ export function TechnicalFormModal({
|
||||
onClick={() => addOption(field.id)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add option
|
||||
{t('categories.technical.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,56 +337,59 @@ export function TechnicalFormModal({
|
||||
))}
|
||||
|
||||
{!fields.length && (
|
||||
<p className={styles.emptyText}>
|
||||
No fields yet. Add text, textarea, select, or multi-select inputs.
|
||||
</p>
|
||||
<p className={styles.emptyText}>{t('categories.technical.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={fieldStyles.toolbar}>
|
||||
{(onGenerateClick ?? onGenerate) && (
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={onGenerateClick ?? onGenerate}
|
||||
disabled={isLoading || isSaving || isGenerating}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{isGenerating ? 'Generating…' : 'Generate with AI'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={styles.addBtn} onClick={addField}>
|
||||
<Plus size={18} />
|
||||
Add field
|
||||
{t('categories.technical.addField')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
{error ? <p className={styles.errorText}>{error}</p> : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSaving || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save form'}
|
||||
</button>
|
||||
<div className={`${styles.actions} ${fieldStyles.actionsRow}`}>
|
||||
<div className={fieldStyles.actionsMain}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving
|
||||
? t('categories.technical.saving')
|
||||
: t('categories.technical.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSaving || isGenerating}
|
||||
>
|
||||
{t('categories.modal.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{(onGenerateClick ?? onGenerate) ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${aiStyles.aiBtn} ${fieldStyles.actionsAi}`}
|
||||
onClick={onGenerateClick ?? onGenerate}
|
||||
disabled={isLoading || isSaving || isGenerating}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{isGenerating
|
||||
? t('products.form.aiGenerating')
|
||||
: t('categories.technical.generateAi')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
}
|
||||
|
||||
.tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translate(-50%, calc(-100% + 4px));
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
@@ -19,8 +18,7 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -28,6 +26,12 @@
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
||||
}
|
||||
|
||||
.tipVisible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -35,12 +39,5 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.wrap:hover .tip,
|
||||
.wrap:focus-within .tip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
border-top-color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Tooltip.module.css'
|
||||
|
||||
interface TooltipProps {
|
||||
@@ -7,12 +8,70 @@ interface TooltipProps {
|
||||
}
|
||||
|
||||
export function Tooltip({ label, children }: TooltipProps) {
|
||||
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
||||
|
||||
function updatePosition() {
|
||||
const el = wrapRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
setCoords({
|
||||
top: rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
}
|
||||
|
||||
function show() {
|
||||
updatePosition()
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
function onReposition() {
|
||||
updatePosition()
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
window.addEventListener('resize', onReposition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<span className={styles.wrap}>
|
||||
<span
|
||||
ref={wrapRef}
|
||||
className={styles.wrap}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocusCapture={show}
|
||||
onBlurCapture={(e) => {
|
||||
if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) {
|
||||
hide()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.tip} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
{visible
|
||||
? createPortal(
|
||||
<span
|
||||
className={`${styles.tip} ${styles.tipVisible}`}
|
||||
role="tooltip"
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: var(--card-hover-transition, transform 0.2s, box-shadow 0.2s);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mainLink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(148, 163, 184, 0.14) 0%,
|
||||
rgba(148, 163, 184, 0.05) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 8px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: 50px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge[data-status='draft'] {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(251, 191, 36, 0.55) 0%,
|
||||
rgba(245, 158, 11, 0.32) 100%
|
||||
);
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.badge[data-status='published'] {
|
||||
color: #047857;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(167, 243, 208, 0.55) 0%,
|
||||
rgba(52, 211, 153, 0.28) 100%
|
||||
);
|
||||
border-color: rgba(110, 231, 183, 0.4);
|
||||
}
|
||||
|
||||
.badge[data-status='archived'] {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(148, 163, 184, 0.45) 0%,
|
||||
rgba(100, 116, 139, 0.28) 100%
|
||||
);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.badge[data-status='rejected'] {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(239, 68, 68, 0.55) 0%,
|
||||
rgba(220, 38, 38, 0.32) 100%
|
||||
);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.promotedBadge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 8px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 50px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(99, 102, 241, 0.7) 0%,
|
||||
rgba(168, 85, 247, 0.45) 100%
|
||||
);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 3px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.categoryChip {
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.owner span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
/* Physical LTR: price left, location right */
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 8px 6px 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.controls button:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls button.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Check,
|
||||
ImageOff,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
Pencil,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { UserProductListItem } from '../types/userProduct'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './UserProductCard.module.css'
|
||||
|
||||
interface UserProductCardProps {
|
||||
product: UserProductListItem
|
||||
to?: string
|
||||
onEdit: (id: string) => void
|
||||
onPromote: (id: string) => void
|
||||
onChangeStatus: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
busyAction?: 'remove' | 'promote' | 'status' | null
|
||||
}
|
||||
|
||||
export function UserProductCard({
|
||||
product,
|
||||
to,
|
||||
onEdit,
|
||||
onPromote,
|
||||
onChangeStatus,
|
||||
onRemove,
|
||||
busyAction = null,
|
||||
}: UserProductCardProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const titleFa = product.titleFa || product.title
|
||||
const titleEn = product.titleEn?.trim() || ''
|
||||
const title = isFa ? titleFa : titleEn || titleFa
|
||||
const secondary = isFa ? titleEn : titleEn ? titleFa : ''
|
||||
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
|
||||
const category = isFa
|
||||
? product.categoryNameFa || product.categoryName
|
||||
: product.categoryName
|
||||
const owner = isFa ? product.ownerNameFa || product.ownerName : product.ownerName
|
||||
const imageSrc = product.imageUrl?.trim() || ''
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
const showImage = Boolean(imageSrc) && !imageFailed
|
||||
const isBusy = busyAction != null
|
||||
const currency = (product.priceCurrency || 'IRT').toUpperCase()
|
||||
const priceLabel =
|
||||
product.price == null
|
||||
? t('customerProducts.priceUnavailable')
|
||||
: currency === 'IRT'
|
||||
? formatIrtPrice(product.price)
|
||||
: `${product.price.toLocaleString('en-US')} ${currency}`
|
||||
|
||||
useEffect(() => {
|
||||
setImageFailed(false)
|
||||
}, [imageSrc])
|
||||
|
||||
const statusLabel =
|
||||
product.status === 'published'
|
||||
? t('customerProducts.status.published')
|
||||
: product.status === 'archived'
|
||||
? t('customerProducts.status.archived')
|
||||
: product.status === 'rejected'
|
||||
? t('customerProducts.status.rejected')
|
||||
: t('customerProducts.status.pending')
|
||||
|
||||
const mediaAndBody = (
|
||||
<>
|
||||
<div className={styles.imageWrap}>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
className={styles.image}
|
||||
loading="lazy"
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.imagePlaceholder} aria-hidden="true">
|
||||
<ImageOff size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
<span className={styles.badge} data-status={product.status}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{product.promoted ? (
|
||||
<span className={styles.promotedBadge}>{t('customerProducts.promoted')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.body} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
|
||||
{category ? <span className={styles.categoryChip}>{category}</span> : null}
|
||||
{owner ? (
|
||||
<p className={styles.owner}>
|
||||
<User size={12} aria-hidden="true" />
|
||||
<span>{owner}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className={styles.metaRow}>
|
||||
<p className={styles.price}>{priceLabel}</p>
|
||||
<p className={styles.location} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<MapPin size={12} aria-hidden="true" />
|
||||
<span>{city}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<article className={styles.card} data-card-hover>
|
||||
{to ? (
|
||||
<Link to={to} className={styles.mainLink}>
|
||||
{mediaAndBody}
|
||||
</Link>
|
||||
) : (
|
||||
mediaAndBody
|
||||
)}
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label={t('customerProducts.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(product.id)}
|
||||
aria-label={t('customerProducts.edit')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
product.promoted
|
||||
? t('customerProducts.promoted')
|
||||
: t('customerProducts.promote')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPromote(product.id)}
|
||||
aria-label={t('customerProducts.promote')}
|
||||
disabled={isBusy || product.promoted}
|
||||
>
|
||||
<Megaphone size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('customerProducts.statusChange')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChangeStatus(product.id)}
|
||||
aria-label={t('customerProducts.statusChange')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('customerProducts.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(product.id)}
|
||||
aria-label={t('customerProducts.remove')}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
cursor: pointer;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
text-align: start;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled) {
|
||||
border-color: rgba(var(--primary-rgb) / 0.35);
|
||||
}
|
||||
|
||||
.option[data-selected='true'] {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.55);
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 0 transparent;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.option[data-selected='true'] .radio {
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 0 0 4px var(--primary);
|
||||
}
|
||||
|
||||
.saving {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { UserProductStatus } from '../types/userProduct'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import styles from './UserProductStatusModal.module.css'
|
||||
|
||||
const STATUSES: UserProductStatus[] = [
|
||||
'draft',
|
||||
'published',
|
||||
'rejected',
|
||||
'archived',
|
||||
]
|
||||
|
||||
interface UserProductStatusModalProps {
|
||||
open: boolean
|
||||
currentStatus: UserProductStatus
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSave: (status: UserProductStatus) => void
|
||||
}
|
||||
|
||||
export function UserProductStatusModal({
|
||||
open,
|
||||
currentStatus,
|
||||
saving = false,
|
||||
onClose,
|
||||
onSave,
|
||||
}: UserProductStatusModalProps) {
|
||||
const t = useT()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function statusLabel(value: UserProductStatus) {
|
||||
if (value === 'draft') return t('customerProducts.status.pending')
|
||||
if (value === 'published') return t('customerProducts.status.published')
|
||||
if (value === 'rejected') return t('customerProducts.status.rejected')
|
||||
return t('customerProducts.status.archived')
|
||||
}
|
||||
|
||||
function handleSelect(value: UserProductStatus) {
|
||||
if (saving) return
|
||||
if (value === currentStatus) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
onSave(value)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={modalStyles.overlay}
|
||||
role="presentation"
|
||||
onClick={saving ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className={modalStyles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-product-status-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<h2 id="user-product-status-title" className={modalStyles.title}>
|
||||
{t('customerProducts.statusModalTitle')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label={t('customerProducts.form.cancel')}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.form}>
|
||||
<p className={styles.hint}>{t('customerProducts.statusModalHint')}</p>
|
||||
<div className={styles.options} role="radiogroup">
|
||||
{STATUSES.map((value) => {
|
||||
const selected = value === currentStatus
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={styles.option}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
disabled={saving}
|
||||
onClick={() => handleSelect(value)}
|
||||
>
|
||||
<span className={styles.radio} aria-hidden />
|
||||
<span>{statusLabel(value)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{saving ? (
|
||||
<p className={styles.saving}>{t('customerProducts.form.submitting')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user