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:
co-authored by
Cursor
parent
0bbc5bbc12
commit
592540cc7e
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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': 'فرم فنی دستهبندی',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user