Add multi_value technical inputs and FA AI prompts.

Support duplicatable list fields in form builder and value UIs, and guide fill prompts to put leftover specs in سایر ویژگی‌ها.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 13:50:13 +03:30
co-authored by Cursor
parent 0bbc5bbc12
commit 592540cc7e
17 changed files with 527 additions and 76 deletions
@@ -34,6 +34,12 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
return labels.length ? labels.join(', ') : '—'
}
if (item.type === 'multi_value') {
const items = Array.isArray(item.value)
? item.value.map((entry) => String(entry).trim()).filter(Boolean)
: []
return items.length ? items.join('، ') : '—'
}
if (item.type === 'select') {
return item.optionLabel ?? (item.value ? String(item.value) : '—')
}
@@ -16,6 +16,7 @@ import type {
TechnicalFieldType,
} from '../types/technicalForm'
import { ProductTechnicalValuesAiPromptModal } from './ProductTechnicalValuesAiPromptModal'
import { TechnicalMultiValueEditor } from './TechnicalMultiValueEditor'
import styles from './VariationsModal.module.css'
import fieldStyles from './TechnicalFormModal.module.css'
import aiStyles from '../styles/ai.module.css'
@@ -135,8 +136,11 @@ export function ProductTechnicalInfoModal({
return form.fields.every((field) => {
if (!field.isRequired) return true
const value = values[field.key]
if (field.type === 'multi_select') {
return Array.isArray(value) && value.length > 0
if (field.type === 'multi_select' || field.type === 'multi_value') {
return (
Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
)
}
return typeof value === 'string' && value.trim().length > 0
})
@@ -226,6 +230,19 @@ export function ProductTechnicalInfoModal({
)
}
if (type === 'multi_value') {
const items = Array.isArray(value) ? value.map(String) : []
return (
<TechnicalMultiValueEditor
id={`tech-${field.key}`}
values={items}
onChange={(next) => setFieldValue(field.key, next)}
addLabel={t('products.technical.addValue')}
removeLabel={t('products.technical.removeValue')}
/>
)
}
return (
<input
id={`tech-${field.key}`}
@@ -1,7 +1,6 @@
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 type { CategoryTechnicalForm } from '../types/technicalForm'
import modalStyles from './CategoryModal.module.css'
@@ -11,22 +10,37 @@ import styles from './CategoryAiPromptModal.module.css'
export function buildProductTechnicalValuesAiPrompt(ctx: {
productName: string
fields: CategoryTechnicalForm['fields']
locale: 'fa' | 'en'
}) {
const productName = ctx.productName || '—'
const hasOtherFeatures = ctx.fields.some(
(field) =>
field.type === 'multi_value' &&
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, '')),
)
const fieldLines = ctx.fields
.map((field) => {
const options =
field.type === 'select' || field.type === 'multi_select'
? field.options.map((option) => option.value).join(' | ')
: ''
const optionPart = options ? ` — options: ${options}` : ''
return `- ${field.key} (${field.label}, ${field.type}${field.isRequired ? ', required' : ''})${optionPart}`
const optionPart = options ? ` — گزینه‌ها: ${options}` : ''
const isOtherFeatures =
field.type === 'multi_value' &&
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, ''))
const multiValueHint = isOtherFeatures
? ' — هر مشخصه/ویژگی دیگری از محصول که در بقیه فیلدها جا نشده (آرایه رشته‌ها)'
: field.type === 'multi_value'
? ' — آرایه‌ای از رشته‌های متنی آزاد'
: ''
return `- ${field.key} (${field.label}, ${field.type}${field.isRequired ? '، الزامی' : ''})${optionPart}${multiValueHint}`
})
.join('\n')
if (ctx.locale === 'fa') {
return `برای محصول زیر، مقادیر فرم اطلاعات فنی را پر کن.
const otherFeaturesRule = hasOtherFeatures
? `- فیلد «سایر ویژگی‌ها» را با هر مشخصات یا ویژگی دیگری از محصول پر کن که در فیلدهای مشخص دیگر جا نمی‌گیرد (مثلاً قابلیت‌ها، لوازم همراه، استانداردها، نکات خاص صفحه محصول). هر مورد را به‌صورت یک آیتم جدا در آرایه بنویس.`
: `- برای فیلدهای multi_value یک آرایه از مقادیر متنی کوتاه بده؛ اگر برچسب آن‌ها «سایر ویژگی‌ها» است، هر مشخصهٔ باقی‌ماندهٔ محصول را آنجا بگذار.`
return `برای محصول زیر، مقادیر فرم اطلاعات فنی را پر کن.
محصول: ${productName}
@@ -36,24 +50,10 @@ ${fieldLines || '—'}
الزامات:
- فقط برای همین فیلدها مقدار بده و از کلیدهای دقیق فیلد استفاده کن.
- برای select / multi_select فقط از مقادیر مجاز گزینه‌ها استفاده کن.
${otherFeaturesRule}
- متن‌ها را به فارسی و کوتاه و فنی بنویس.
- مشخصات غیرواقعی اختراع نکن؛ اگر مطمئن نیستی آن فیلد را خالی بگذار.
- در صورت نیاز، لینک صفحه محصول را در ادامه همین پرامپت اضافه کن (سیستم صفحه را باز می‌کند و از متن آن استفاده می‌کند).`
}
return `Fill the technical data form values for this product.
Product: ${productName}
Form fields:
${fieldLines || '—'}
Requirements:
- Fill only these fields using the exact field keys.
- For select / multi_select use only allowed option values.
- Keep text values short, factual, and in the same language as the field labels (usually Persian).
- Do not invent unrealistic specs; leave a field empty if unsure.
- Optionally paste a product page URL below this prompt (the system will open the page and use its text).`
}
interface ProductTechnicalValuesAiPromptModalProps {
@@ -76,8 +76,6 @@ export function ProductTechnicalValuesAiPromptModal({
onRun,
}: ProductTechnicalValuesAiPromptModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState('')
@@ -91,7 +89,6 @@ export function ProductTechnicalValuesAiPromptModal({
buildProductTechnicalValuesAiPrompt({
productName,
fields,
locale: isFa ? 'fa' : 'en',
}),
)
setError('')
@@ -103,7 +100,7 @@ export function ProductTechnicalValuesAiPromptModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, productName, fields, isFa])
}, [open, mounted, productName, fields])
useEffect(() => {
if (!mounted || closing) return
@@ -146,8 +143,8 @@ export function ProductTechnicalValuesAiPromptModal({
role="dialog"
aria-modal="true"
aria-labelledby="product-technical-ai-prompt-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
@@ -178,8 +175,8 @@ export function ProductTechnicalValuesAiPromptModal({
rows={14}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
/>
</div>
@@ -1,40 +1,26 @@
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,
locale: 'fa' | 'en' = 'en',
) {
if (locale === 'fa') {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
export function buildTechnicalFormAiPrompt(categoryName: string) {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
برای هر فیلد این موارد را مشخص کن:
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، رنگ‌ها)
- نوع ورودی: text، textarea، select یا multi_select
- نوع ورودی: text، textarea، select، multi_select یا multi_value
- الزامی بودن یا نبودن
- برای select / multi_select: فهرست گزینه‌ها به فارسی
- multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگی‌های چندتایی
همیشه در انتهای فرم یک فیلد «سایر ویژگی‌ها» از نوع multi_value در نظر گرفته می‌شود؛ خودت آن را نساز.
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها و گزینه‌ها را به فارسی بنویس.
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).`
}
return `Suggest technical data form fields for the "${categoryName}" product category.
For each field include:
- A short English label (e.g. "Screen Size", "Material", "Color")
- The input type: text, textarea, select, or multi_select
- Whether it is required
- For select / multi_select: the list of options
Focus on specs and attributes that shoppers typically compare when buying this type of product.
Optionally paste a sample product page URL below (the system will open the page and build the form from its real specs).`
}
interface TechnicalFormAiPromptModalProps {
@@ -55,20 +41,16 @@ export function TechnicalFormAiPromptModal({
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, isFa ? 'fa' : 'en'),
)
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
setPrompt(buildTechnicalFormAiPrompt(categoryName))
setError('')
} else if (mounted) {
setClosing(true)
@@ -78,7 +60,7 @@ export function TechnicalFormAiPromptModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, categoryName, isFa])
}, [open, mounted, categoryName])
useEffect(() => {
if (!mounted || closing) return
@@ -121,8 +103,8 @@ export function TechnicalFormAiPromptModal({
role="dialog"
aria-modal="true"
aria-labelledby="tech-form-ai-prompt-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
@@ -153,8 +135,8 @@ export function TechnicalFormAiPromptModal({
rows={14}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
/>
</div>
@@ -34,6 +34,7 @@ const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
textarea: 'categories.technical.type.textarea',
select: 'categories.technical.type.select',
multi_select: 'categories.technical.type.multi_select',
multi_value: 'categories.technical.type.multi_value',
}
function createEmptyField(): TechnicalFormFieldDraft {
@@ -0,0 +1,68 @@
.root {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
.row input {
width: 100%;
min-height: var(--field-height, 40px);
padding: var(--field-padding-y, 8px) var(--field-padding-x, 12px);
font-size: var(--field-font-size, 14px);
font-family: inherit;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm, 8px);
outline: none;
box-sizing: border-box;
}
.row input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.removeBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--text-secondary);
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm, 8px);
cursor: pointer;
}
.removeBtn:hover {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.35);
}
.addBtn {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
color: var(--primary);
background: transparent;
border: 1px dashed rgba(var(--primary-rgb) / 0.45);
border-radius: var(--radius-sm, 8px);
cursor: pointer;
}
.addBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
@@ -0,0 +1,69 @@
import { Plus, Trash2 } from 'lucide-react'
import styles from './TechnicalMultiValueEditor.module.css'
interface TechnicalMultiValueEditorProps {
id?: string
values: string[]
onChange: (values: string[]) => void
addLabel: string
removeLabel: string
placeholder?: string
}
export function TechnicalMultiValueEditor({
id,
values,
onChange,
addLabel,
removeLabel,
placeholder,
}: TechnicalMultiValueEditorProps) {
const rows = values.length > 0 ? values : ['']
function updateRow(index: number, next: string) {
const copy = [...rows]
copy[index] = next
onChange(copy)
}
function removeRow(index: number) {
if (rows.length <= 1) {
onChange([''])
return
}
onChange(rows.filter((_, i) => i !== index))
}
function addRow() {
onChange([...rows, ''])
}
return (
<div className={styles.root}>
{rows.map((item, index) => (
<div key={`${id ?? 'mv'}-${index}`} className={styles.row}>
<input
id={index === 0 ? id : undefined}
type="text"
value={item}
placeholder={placeholder}
onChange={(e) => updateRow(index, e.target.value)}
/>
<button
type="button"
className={styles.removeBtn}
onClick={() => removeRow(index)}
aria-label={removeLabel}
title={removeLabel}
>
<Trash2 size={15} />
</button>
</div>
))}
<button type="button" className={styles.addBtn} onClick={addRow}>
<Plus size={15} />
{addLabel}
</button>
</div>
)
}
+10
View File
@@ -488,6 +488,8 @@ const en = {
'customerProducts.form.images.galleryHint': 'You can select multiple images.',
'customerProducts.form.images.galleryRemove': 'Remove image {index}',
'customerProducts.form.technical.select': 'Select…',
'customerProducts.form.technical.addValue': 'Add value',
'customerProducts.form.technical.removeValue': 'Remove value',
'customerProducts.form.technical.needCategory': 'Choose a category in step 1 to load technical fields.',
'customerProducts.form.technical.empty': 'This category has no technical fields yet.',
'customerProducts.form.technical.categoryForm': 'Category technical form',
@@ -652,6 +654,8 @@ const en = {
'products.technical.save': 'Save',
'products.technical.saving': 'Saving…',
'products.technical.selectPlaceholder': 'Select...',
'products.technical.addValue': 'Add value',
'products.technical.removeValue': 'Remove value',
'products.technical.generateAi': 'Fill with AI',
'products.technical.ai.title': 'Fill Technical Data with AI',
'products.technical.ai.subtitle':
@@ -827,6 +831,7 @@ const en = {
'categories.technical.type.textarea': 'Textarea',
'categories.technical.type.select': 'Select',
'categories.technical.type.multi_select': 'Multi',
'categories.technical.type.multi_value': 'Multiple values',
'categories.technical.ai.title': 'Generate Technical Form with AI',
'categories.technical.ai.subtitle':
'Edit the prompt below. If you paste a product page URL, the system opens it and builds fields from that page.',
@@ -3080,6 +3085,8 @@ const fa: Record<MessageKey, string> = {
'customerProducts.form.images.galleryHint': 'می‌توانید چند تصویر انتخاب کنید.',
'customerProducts.form.images.galleryRemove': 'حذف تصویر {index}',
'customerProducts.form.technical.select': 'انتخاب کنید…',
'customerProducts.form.technical.addValue': 'افزودن مقدار',
'customerProducts.form.technical.removeValue': 'حذف مقدار',
'customerProducts.form.technical.needCategory': 'برای نمایش فیلدهای فنی، در مرحله ۱ دسته‌بندی را انتخاب کنید.',
'customerProducts.form.technical.empty': 'برای این دسته‌بندی هنوز فیلد فنی تعریف نشده است.',
'customerProducts.form.technical.categoryForm': 'فرم فنی دسته‌بندی',
@@ -3242,6 +3249,8 @@ const fa: Record<MessageKey, string> = {
'products.technical.save': 'ذخیره',
'products.technical.saving': 'در حال ذخیره…',
'products.technical.selectPlaceholder': 'انتخاب…',
'products.technical.addValue': 'افزودن مقدار',
'products.technical.removeValue': 'حذف مقدار',
'products.technical.generateAi': 'پر کردن با هوش مصنوعی',
'products.technical.ai.title': 'پر کردن اطلاعات فنی با هوش مصنوعی',
'products.technical.ai.subtitle':
@@ -3417,6 +3426,7 @@ const fa: Record<MessageKey, string> = {
'categories.technical.type.textarea': 'چندخطی',
'categories.technical.type.select': 'انتخابی',
'categories.technical.type.multi_select': 'چندانتخابی',
'categories.technical.type.multi_value': 'چند مقداری',
'categories.technical.ai.title': 'تولید فرم فنی با هوش مصنوعی',
'categories.technical.ai.subtitle':
'پرامپت را ویرایش کنید. اگر لینک صفحه محصول بگذارید، سیستم صفحه را باز می‌کند و فیلدها را بر اساس آن می‌سازد.',
@@ -24,6 +24,7 @@ import {
createUserProduct,
getUserProduct,
getUserProductCategoryTechnicalForm,
hydrateTechnicalFormValues,
listUserProductCategories,
updateUserProduct,
type TechnicalFormField,
@@ -36,6 +37,7 @@ import {
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta'
import { TechnicalMultiValueEditor } from '../components/TechnicalMultiValueEditor'
import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3
@@ -266,7 +268,12 @@ export function AddCustomerProductPage() {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
setTechnicalValues(
hydrateTechnicalFormValues(
response.form?.fields ?? [],
pendingTechnicalValuesRef.current,
),
)
pendingTechnicalValuesRef.current = null
}
})
@@ -321,8 +328,9 @@ export function AddCustomerProductPage() {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
field.type === 'multi_select' || field.type === 'multi_value'
? Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
return t('customerProducts.form.error.technicalRequired')
@@ -483,6 +491,19 @@ export function AddCustomerProductPage() {
)
}
if (field.type === 'multi_value') {
const items = Array.isArray(value) ? value.map(String) : []
return (
<TechnicalMultiValueEditor
id={id}
values={items}
onChange={(next) => setTechnicalField(field.id, next)}
addLabel={t('customerProducts.form.technical.addValue')}
removeLabel={t('customerProducts.form.technical.removeValue')}
/>
)
}
return (
<input
id={id}
@@ -28,7 +28,8 @@ export function valuesToFormState(
if (form) {
for (const field of form.fields) {
state[field.key] = field.type === 'multi_select' ? [] : ''
state[field.key] =
field.type === 'multi_select' || field.type === 'multi_value' ? [] : ''
}
}
@@ -43,11 +44,19 @@ export function valuesToFormState(
export function formStateToPayload(values: ProductTechnicalFormValues) {
return {
values: Object.entries(values)
.filter(([, value]) => {
if (Array.isArray(value)) return value.length > 0
return value !== ''
.map(([fieldKey, value]) => {
if (Array.isArray(value)) {
return {
fieldKey,
value: value.map((item) => String(item).trim()).filter(Boolean),
}
}
return { fieldKey, value }
})
.map(([fieldKey, value]) => ({ fieldKey, value })),
.filter((entry) => {
if (Array.isArray(entry.value)) return entry.value.length > 0
return entry.value !== ''
}),
}
}
@@ -4,7 +4,12 @@ import type { UserProductListItem, UserProductStatus } from '../types/userProduc
export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export type TechnicalFieldType =
| 'text'
| 'textarea'
| 'select'
| 'multi_select'
| 'multi_value'
export interface UserProductCategoryOption {
id: string
@@ -109,6 +114,44 @@ function businessPath(suffix = '') {
return `/businesses/${businessId}/user-products${suffix}`
}
/** Decode free-text list values stored as JSON in text_value. */
export function decodeMultiValueText(raw: string | null | undefined): string[] {
if (raw == null) return []
const trimmed = raw.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item).trim()).filter(Boolean)
}
} catch {
// fall through
}
}
return [trimmed]
}
export function hydrateTechnicalFormValues(
fields: TechnicalFormField[],
values: TechnicalFormValues,
): TechnicalFormValues {
const next: TechnicalFormValues = { ...values }
for (const field of fields) {
if (field.type !== 'multi_value') continue
const current = next[field.id]
if (Array.isArray(current)) continue
if (typeof current === 'string') {
next[field.id] = decodeMultiValueText(current)
} else {
next[field.id] = []
}
}
return next
}
export function buildTechnicalValuesPayload(
fields: TechnicalFormField[],
values: TechnicalFormValues,
@@ -125,6 +168,15 @@ export function buildTechnicalValuesPayload(
continue
}
if (field.type === 'multi_value') {
const items = Array.isArray(value)
? value.map((item) => String(item).trim()).filter(Boolean)
: []
if (!items.length) continue
payload.push({ fieldId: field.id, textValue: JSON.stringify(items) })
continue
}
if (field.type === 'select') {
const optionId = typeof value === 'string' ? value.trim() : ''
if (!optionId) continue
+6 -1
View File
@@ -1,4 +1,9 @@
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export type TechnicalFieldType =
| 'text'
| 'textarea'
| 'select'
| 'multi_select'
| 'multi_value'
export interface TechnicalFormFieldOption {
id: string