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
@@ -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>
)
}
+4
View File
@@ -267,6 +267,8 @@ const en = {
'myProducts.images.galleryHint': 'You can select multiple images.',
'myProducts.images.galleryRemove': 'Remove image {index}',
'myProducts.technical.select': 'Select…',
'myProducts.technical.addValue': 'Add value',
'myProducts.technical.removeValue': 'Remove value',
'myProducts.technical.needCategory': 'Choose a category in step 1 to load technical fields.',
'myProducts.technical.empty': 'This category has no technical fields yet.',
'myProducts.technical.categoryForm': 'Category technical form',
@@ -669,6 +671,8 @@ const fa: Record<MessageKey, string> = {
'myProducts.images.galleryHint': 'می‌توانید چند تصویر انتخاب کنید.',
'myProducts.images.galleryRemove': 'حذف تصویر {index}',
'myProducts.technical.select': 'انتخاب کنید…',
'myProducts.technical.addValue': 'افزودن مقدار',
'myProducts.technical.removeValue': 'حذف مقدار',
'myProducts.technical.needCategory': 'برای نمایش فیلدهای فنی، در مرحله ۱ دسته‌بندی را انتخاب کنید.',
'myProducts.technical.empty': 'برای این دسته‌بندی هنوز فیلد فنی تعریف نشده است.',
'myProducts.technical.categoryForm': 'فرم فنی دسته‌بندی',
+24 -3
View File
@@ -21,6 +21,7 @@ import {
createMyUserProduct,
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
hydrateTechnicalFormValues,
listMyUserProductCategories,
updateMyUserProduct,
type TechnicalFormField,
@@ -32,6 +33,7 @@ import {
} from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { TechnicalMultiValueEditor } from '../components/TechnicalMultiValueEditor'
import styles from './AddMyProductPage.module.css'
type StepId = 1 | 2 | 3
@@ -258,7 +260,12 @@ export function AddMyProductPage() {
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
}
})
@@ -313,8 +320,9 @@ export function AddMyProductPage() {
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('myProducts.error.technicalRequired')
@@ -474,6 +482,19 @@ export function AddMyProductPage() {
)
}
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('myProducts.technical.addValue')}
removeLabel={t('myProducts.technical.removeValue')}
/>
)
}
return (
<input
id={id}
@@ -4,7 +4,12 @@ import type { UserProductListItem } from '../types/userProduct'
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
@@ -97,6 +102,44 @@ function businessPath(suffix = '') {
return `/businesses/${businessId}/my-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,
@@ -113,6 +156,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