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>
209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Sparkles, X } from 'lucide-react'
|
|
import { useT } from '../i18n/useT'
|
|
import type { CategoryTechnicalForm } from '../types/technicalForm'
|
|
import modalStyles from './CategoryModal.module.css'
|
|
import aiStyles from '../styles/ai.module.css'
|
|
import styles from './CategoryAiPromptModal.module.css'
|
|
|
|
export function buildProductTechnicalValuesAiPrompt(ctx: {
|
|
productName: string
|
|
fields: CategoryTechnicalForm['fields']
|
|
}) {
|
|
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}` : ''
|
|
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')
|
|
|
|
const otherFeaturesRule = hasOtherFeatures
|
|
? `- فیلد «سایر ویژگیها» را با هر مشخصات یا ویژگی دیگری از محصول پر کن که در فیلدهای مشخص دیگر جا نمیگیرد (مثلاً قابلیتها، لوازم همراه، استانداردها، نکات خاص صفحه محصول). هر مورد را بهصورت یک آیتم جدا در آرایه بنویس.`
|
|
: `- برای فیلدهای multi_value یک آرایه از مقادیر متنی کوتاه بده؛ اگر برچسب آنها «سایر ویژگیها» است، هر مشخصهٔ باقیماندهٔ محصول را آنجا بگذار.`
|
|
|
|
return `برای محصول زیر، مقادیر فرم اطلاعات فنی را پر کن.
|
|
|
|
محصول: ${productName}
|
|
|
|
فیلدهای فرم:
|
|
${fieldLines || '—'}
|
|
|
|
الزامات:
|
|
- فقط برای همین فیلدها مقدار بده و از کلیدهای دقیق فیلد استفاده کن.
|
|
- برای select / multi_select فقط از مقادیر مجاز گزینهها استفاده کن.
|
|
${otherFeaturesRule}
|
|
- متنها را به فارسی و کوتاه و فنی بنویس.
|
|
- مشخصات غیرواقعی اختراع نکن؛ اگر مطمئن نیستی آن فیلد را خالی بگذار.
|
|
- در صورت نیاز، لینک صفحه محصول را در ادامه همین پرامپت اضافه کن (سیستم صفحه را باز میکند و از متن آن استفاده میکند).`
|
|
}
|
|
|
|
interface ProductTechnicalValuesAiPromptModalProps {
|
|
open: boolean
|
|
productName: string
|
|
fields: CategoryTechnicalForm['fields']
|
|
isRunning: boolean
|
|
onClose: () => void
|
|
onRun: (prompt: string) => Promise<void>
|
|
}
|
|
|
|
const ANIMATION_MS = 220
|
|
|
|
export function ProductTechnicalValuesAiPromptModal({
|
|
open,
|
|
productName,
|
|
fields,
|
|
isRunning,
|
|
onClose,
|
|
onRun,
|
|
}: ProductTechnicalValuesAiPromptModalProps) {
|
|
const t = useT()
|
|
const [mounted, setMounted] = useState(open)
|
|
const [closing, setClosing] = useState(false)
|
|
const [prompt, setPrompt] = useState('')
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true)
|
|
setClosing(false)
|
|
setPrompt(
|
|
buildProductTechnicalValuesAiPrompt({
|
|
productName,
|
|
fields,
|
|
}),
|
|
)
|
|
setError('')
|
|
} else if (mounted) {
|
|
setClosing(true)
|
|
const timer = setTimeout(() => {
|
|
setMounted(false)
|
|
setClosing(false)
|
|
}, ANIMATION_MS)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [open, mounted, productName, fields])
|
|
|
|
useEffect(() => {
|
|
if (!mounted || closing) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && !isRunning) onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [mounted, closing, isRunning, onClose])
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
const trimmed = prompt.trim()
|
|
if (trimmed.length < 10) {
|
|
setError(t('products.form.aiPromptTooShort'))
|
|
return
|
|
}
|
|
setError('')
|
|
try {
|
|
await onRun(trimmed)
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.technical.ai.error'))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!mounted) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
|
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="product-technical-ai-prompt-title"
|
|
lang="fa"
|
|
dir="rtl"
|
|
>
|
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
|
<div className={styles.headerBlock}>
|
|
<h2 id="product-technical-ai-prompt-title" className={modalStyles.title}>
|
|
{t('products.technical.ai.title')}
|
|
</h2>
|
|
<p className={styles.subtitle}>{t('products.technical.ai.subtitle')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={modalStyles.closeBtn}
|
|
onClick={onClose}
|
|
disabled={isRunning}
|
|
aria-label={t('common.close')}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
|
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
|
<label htmlFor="product-technical-ai-prompt">{t('products.form.aiPrompt')}</label>
|
|
<textarea
|
|
id="product-technical-ai-prompt"
|
|
className={aiStyles.aiPromptTextarea}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
rows={14}
|
|
disabled={isRunning}
|
|
required
|
|
lang="fa"
|
|
dir="rtl"
|
|
/>
|
|
</div>
|
|
|
|
{error ? <p className={styles.error}>{error}</p> : null}
|
|
|
|
<div className={modalStyles.actions}>
|
|
<button
|
|
type="button"
|
|
className={modalStyles.cancelBtn}
|
|
onClick={onClose}
|
|
disabled={isRunning}
|
|
>
|
|
{t('products.form.cancel')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className={aiStyles.aiBtn}
|
|
disabled={isRunning || prompt.trim().length < 10}
|
|
>
|
|
<Sparkles size={16} />
|
|
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|