From 592540cc7e6813acfc3b3a6c1c91daafcdb2f8e8 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 19 Sep 2026 13:50:13 +0330 Subject: [PATCH] Add multi_value technical inputs and FA AI prompts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support duplicatable list fields in form builder and value UIs, and guide fill prompts to put leftover specs in سایر ویژگی‌ها. Co-authored-by: Cursor --- .../src/components/ProductDetailsTabs.tsx | 6 ++ .../components/ProductTechnicalInfoModal.tsx | 21 +++++- .../ProductTechnicalValuesAiPromptModal.tsx | 55 +++++++-------- .../components/TechnicalFormAiPromptModal.tsx | 44 ++++-------- .../src/components/TechnicalFormModal.tsx | 1 + .../TechnicalMultiValueEditor.module.css | 68 ++++++++++++++++++ .../components/TechnicalMultiValueEditor.tsx | 69 +++++++++++++++++++ apps/business/src/i18n/messages.ts | 10 +++ .../src/pages/AddCustomerProductPage.tsx | 27 +++++++- .../services/productTechnicalInfoService.ts | 19 +++-- .../src/services/userProductsService.ts | 54 ++++++++++++++- apps/business/src/types/technicalForm.ts | 7 +- .../TechnicalMultiValueEditor.module.css | 68 ++++++++++++++++++ .../components/TechnicalMultiValueEditor.tsx | 69 +++++++++++++++++++ apps/customer/src/i18n/messages.ts | 4 ++ apps/customer/src/pages/AddMyProductPage.tsx | 27 +++++++- .../src/services/userProductsService.ts | 54 ++++++++++++++- 17 files changed, 527 insertions(+), 76 deletions(-) create mode 100644 apps/business/src/components/TechnicalMultiValueEditor.module.css create mode 100644 apps/business/src/components/TechnicalMultiValueEditor.tsx create mode 100644 apps/customer/src/components/TechnicalMultiValueEditor.module.css create mode 100644 apps/customer/src/components/TechnicalMultiValueEditor.tsx diff --git a/apps/business/src/components/ProductDetailsTabs.tsx b/apps/business/src/components/ProductDetailsTabs.tsx index 598cef4..f5687b5 100644 --- a/apps/business/src/components/ProductDetailsTabs.tsx +++ b/apps/business/src/components/ProductDetailsTabs.tsx @@ -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) : '—') } diff --git a/apps/business/src/components/ProductTechnicalInfoModal.tsx b/apps/business/src/components/ProductTechnicalInfoModal.tsx index 539bc8e..f797375 100644 --- a/apps/business/src/components/ProductTechnicalInfoModal.tsx +++ b/apps/business/src/components/ProductTechnicalInfoModal.tsx @@ -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 ( + setFieldValue(field.key, next)} + addLabel={t('products.technical.addValue')} + removeLabel={t('products.technical.removeValue')} + /> + ) + } + return ( + 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" >
@@ -178,8 +175,8 @@ export function ProductTechnicalValuesAiPromptModal({ rows={14} disabled={isRunning} required - lang={isFa ? 'fa' : 'en'} - dir={isFa ? 'rtl' : 'ltr'} + lang="fa" + dir="rtl" />
diff --git a/apps/business/src/components/TechnicalFormAiPromptModal.tsx b/apps/business/src/components/TechnicalFormAiPromptModal.tsx index 8a62713..6de6ee3 100644 --- a/apps/business/src/components/TechnicalFormAiPromptModal.tsx +++ b/apps/business/src/components/TechnicalFormAiPromptModal.tsx @@ -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" >
@@ -153,8 +135,8 @@ export function TechnicalFormAiPromptModal({ rows={14} disabled={isRunning} required - lang={isFa ? 'fa' : 'en'} - dir={isFa ? 'rtl' : 'ltr'} + lang="fa" + dir="rtl" />
diff --git a/apps/business/src/components/TechnicalFormModal.tsx b/apps/business/src/components/TechnicalFormModal.tsx index c7782d2..e5b8742 100644 --- a/apps/business/src/components/TechnicalFormModal.tsx +++ b/apps/business/src/components/TechnicalFormModal.tsx @@ -34,6 +34,7 @@ const FIELD_TYPE_KEYS: Record = { 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 { diff --git a/apps/business/src/components/TechnicalMultiValueEditor.module.css b/apps/business/src/components/TechnicalMultiValueEditor.module.css new file mode 100644 index 0000000..db0392d --- /dev/null +++ b/apps/business/src/components/TechnicalMultiValueEditor.module.css @@ -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); +} diff --git a/apps/business/src/components/TechnicalMultiValueEditor.tsx b/apps/business/src/components/TechnicalMultiValueEditor.tsx new file mode 100644 index 0000000..f92361e --- /dev/null +++ b/apps/business/src/components/TechnicalMultiValueEditor.tsx @@ -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 ( +
+ {rows.map((item, index) => ( +
+ updateRow(index, e.target.value)} + /> + +
+ ))} + +
+ ) +} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index ffe4a88..4bcb2c2 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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 = { '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': 'پرامپت را ویرایش کنید. اگر لینک صفحه محصول بگذارید، سیستم صفحه را باز می‌کند و فیلدها را بر اساس آن می‌سازد.', diff --git a/apps/business/src/pages/AddCustomerProductPage.tsx b/apps/business/src/pages/AddCustomerProductPage.tsx index 51f202c..e3e285c 100644 --- a/apps/business/src/pages/AddCustomerProductPage.tsx +++ b/apps/business/src/pages/AddCustomerProductPage.tsx @@ -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 ( + setTechnicalField(field.id, next)} + addLabel={t('customerProducts.form.technical.addValue')} + removeLabel={t('customerProducts.form.technical.removeValue')} + /> + ) + } + return ( { - 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 !== '' + }), } } diff --git a/apps/business/src/services/userProductsService.ts b/apps/business/src/services/userProductsService.ts index e7f484f..402426c 100644 --- a/apps/business/src/services/userProductsService.ts +++ b/apps/business/src/services/userProductsService.ts @@ -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 diff --git a/apps/business/src/types/technicalForm.ts b/apps/business/src/types/technicalForm.ts index 59f0867..90e53e0 100644 --- a/apps/business/src/types/technicalForm.ts +++ b/apps/business/src/types/technicalForm.ts @@ -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 diff --git a/apps/customer/src/components/TechnicalMultiValueEditor.module.css b/apps/customer/src/components/TechnicalMultiValueEditor.module.css new file mode 100644 index 0000000..db0392d --- /dev/null +++ b/apps/customer/src/components/TechnicalMultiValueEditor.module.css @@ -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); +} diff --git a/apps/customer/src/components/TechnicalMultiValueEditor.tsx b/apps/customer/src/components/TechnicalMultiValueEditor.tsx new file mode 100644 index 0000000..f92361e --- /dev/null +++ b/apps/customer/src/components/TechnicalMultiValueEditor.tsx @@ -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 ( +
+ {rows.map((item, index) => ( +
+ updateRow(index, e.target.value)} + /> + +
+ ))} + +
+ ) +} diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index e534fef..01e3875 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -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 = { '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': 'فرم فنی دسته‌بندی', diff --git a/apps/customer/src/pages/AddMyProductPage.tsx b/apps/customer/src/pages/AddMyProductPage.tsx index 6beb180..e5ca509 100644 --- a/apps/customer/src/pages/AddMyProductPage.tsx +++ b/apps/customer/src/pages/AddMyProductPage.tsx @@ -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 ( + setTechnicalField(field.id, next)} + addLabel={t('myProducts.technical.addValue')} + removeLabel={t('myProducts.technical.removeValue')} + /> + ) + } + return ( 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