mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add product AI fill, Enter-to-filter, and URL-persisted product filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ad83fc1099
commit
4d480799b0
@@ -21,6 +21,9 @@
|
|||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: repeat(12, 1fr);
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filtersInputs {
|
.filtersInputs {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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 { ProductAiFillField } from '../services/productAiService'
|
||||||
|
import modalStyles from './CategoryModal.module.css'
|
||||||
|
import aiStyles from '../styles/ai.module.css'
|
||||||
|
import styles from './CategoryAiPromptModal.module.css'
|
||||||
|
|
||||||
|
export function buildProductSummaryAiPrompt(ctx: {
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
categoryName: string
|
||||||
|
locale: 'fa' | 'en'
|
||||||
|
}) {
|
||||||
|
const nameFa = ctx.nameFa || '—'
|
||||||
|
const nameEn = ctx.nameEn || '—'
|
||||||
|
const category = ctx.categoryName || '—'
|
||||||
|
|
||||||
|
if (ctx.locale === 'fa') {
|
||||||
|
return `یک خلاصه کوتاه و فنی برای کارت محصول بنویس (۱ تا ۳ جمله).
|
||||||
|
|
||||||
|
نام محصول (فارسی): ${nameFa}
|
||||||
|
نام محصول (انگلیسی): ${nameEn}
|
||||||
|
دستهبندی: ${category}
|
||||||
|
|
||||||
|
الزامات:
|
||||||
|
- اول بگو این محصول چیست (نوع / ماهیت کالا).
|
||||||
|
- بعد توضیح بده چرا از نظر ترکیب، کاربرد یا ویژگی فنی خوب است.
|
||||||
|
- لحن اطلاعاتی و تخصصی باشد، نه تبلیغاتی.
|
||||||
|
- از عبارات فروش مثل «انتخاب عالی»، «تجربه فوقالعاده»، «با قیمت مناسب بخرید»، «از ما خریداری کنید» استفاده نکن.
|
||||||
|
- مشخصات یا ادعاهایی که مطمئن نیستی اختراع نکن.
|
||||||
|
- خلاصه را به فارسی بنویس.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Write a short, technical product listing summary (1–3 sentences).
|
||||||
|
|
||||||
|
Product name (FA): ${nameFa}
|
||||||
|
Product name (EN): ${nameEn}
|
||||||
|
Category: ${category}
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- First say what the product is (type / nature of the item).
|
||||||
|
- Then explain why it is good in terms of composition, use, or technical traits.
|
||||||
|
- Keep an informative, specialist tone — not advertising.
|
||||||
|
- Avoid sales phrases like "excellent choice", "amazing experience", "buy now", "great price from us".
|
||||||
|
- Do not invent specs or claims you are unsure about.
|
||||||
|
- Write the summary in Persian (Farsi).`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProductDescriptionAiPrompt(ctx: {
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
categoryName: string
|
||||||
|
locale: 'fa' | 'en'
|
||||||
|
}) {
|
||||||
|
const nameFa = ctx.nameFa || '—'
|
||||||
|
const nameEn = ctx.nameEn || '—'
|
||||||
|
const category = ctx.categoryName || '—'
|
||||||
|
|
||||||
|
if (ctx.locale === 'fa') {
|
||||||
|
return `یک توضیحات فنی محصول به صورت HTML بنویس.
|
||||||
|
|
||||||
|
نام محصول (فارسی): ${nameFa}
|
||||||
|
نام محصول (انگلیسی): ${nameEn}
|
||||||
|
دستهبندی: ${category}
|
||||||
|
|
||||||
|
ساختار پیشنهادی:
|
||||||
|
1) این محصول چیست؟
|
||||||
|
2) ترکیب / مواد / ویژگیهای فنی مهم
|
||||||
|
3) کاربرد و اینکه چرا برای آن کاربرد مناسب است
|
||||||
|
4) در صورت نیاز، فهرست نقطهای نکات کلیدی
|
||||||
|
|
||||||
|
الزامات:
|
||||||
|
- لحن اطلاعاتی و تخصصی؛ نه شعار تبلیغاتی.
|
||||||
|
- از دعوت به خرید، تخفیف، «بهترین انتخاب»، و جملات بازاریابی مبهم پرهیز کن.
|
||||||
|
- مشخصات ساختگی ننویس؛ اگر جزئیات دقیق نداری، روی تعریف محصول و کاربردهای معمول تمرکز کن.
|
||||||
|
- ۲ تا ۴ پاراگراف کوتاه به فارسی.
|
||||||
|
- فقط HTML با تگهای <p>، <ul>، <li>، <strong> و <em> برگردان.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Write a technical product description as HTML.
|
||||||
|
|
||||||
|
Product name (FA): ${nameFa}
|
||||||
|
Product name (EN): ${nameEn}
|
||||||
|
Category: ${category}
|
||||||
|
|
||||||
|
Suggested structure:
|
||||||
|
1) What is this product?
|
||||||
|
2) Composition / materials / important technical traits
|
||||||
|
3) Use cases and why it fits those uses
|
||||||
|
4) Optional bullet list of key points
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Informative, specialist tone — not advertising slogans.
|
||||||
|
- Avoid CTAs, discounts, "best choice", and vague marketing claims.
|
||||||
|
- Do not invent specs; if details are unknown, focus on what the product is and typical uses.
|
||||||
|
- 2–4 short paragraphs in Persian (Farsi).
|
||||||
|
- Return HTML only using <p>, <ul>, <li>, <strong>, and <em>.`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductFieldAiPromptModalProps {
|
||||||
|
open: boolean
|
||||||
|
field: ProductAiFillField
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
categoryName: string
|
||||||
|
isRunning: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onRun: (prompt: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ProductFieldAiPromptModal({
|
||||||
|
open,
|
||||||
|
field,
|
||||||
|
nameFa,
|
||||||
|
nameEn,
|
||||||
|
categoryName,
|
||||||
|
isRunning,
|
||||||
|
onClose,
|
||||||
|
onRun,
|
||||||
|
}: ProductFieldAiPromptModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
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)
|
||||||
|
const localeKey = isFa ? 'fa' : 'en'
|
||||||
|
setPrompt(
|
||||||
|
field === 'summary'
|
||||||
|
? buildProductSummaryAiPrompt({
|
||||||
|
nameFa,
|
||||||
|
nameEn,
|
||||||
|
categoryName,
|
||||||
|
locale: localeKey,
|
||||||
|
})
|
||||||
|
: buildProductDescriptionAiPrompt({
|
||||||
|
nameFa,
|
||||||
|
nameEn,
|
||||||
|
categoryName,
|
||||||
|
locale: localeKey,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted, field, nameFa, nameEn, categoryName, isFa])
|
||||||
|
|
||||||
|
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.form.aiFillError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
const title =
|
||||||
|
field === 'summary'
|
||||||
|
? t('products.form.aiFillSummaryTitle')
|
||||||
|
: t('products.form.aiFillDescriptionTitle')
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.overlay} ${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-field-ai-prompt-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
|
<div className={styles.headerBlock}>
|
||||||
|
<h2 id="product-field-ai-prompt-title" className={modalStyles.title}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.subtitle}>{t('products.form.aiFillSubtitle')}</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-field-ai-prompt">{t('products.form.aiPrompt')}</label>
|
||||||
|
<textarea
|
||||||
|
id="product-field-ai-prompt"
|
||||||
|
className={aiStyles.aiPromptTextarea}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
disabled={isRunning}
|
||||||
|
required
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -271,6 +271,18 @@ const en = {
|
|||||||
'products.form.summaryPlaceholder': 'Short summary shown in product listings',
|
'products.form.summaryPlaceholder': 'Short summary shown in product listings',
|
||||||
'products.form.description': 'Description',
|
'products.form.description': 'Description',
|
||||||
'products.form.descriptionPlaceholder': 'Full product description with formatting...',
|
'products.form.descriptionPlaceholder': 'Full product description with formatting...',
|
||||||
|
'products.form.aiFill': 'AI',
|
||||||
|
'products.form.aiFillSummaryTitle': 'Generate summary with AI',
|
||||||
|
'products.form.aiFillDescriptionTitle': 'Generate description with AI',
|
||||||
|
'products.form.aiFillSubtitle': 'Edit the prompt below, then run it to fill this field.',
|
||||||
|
'products.form.aiPrompt': 'Prompt',
|
||||||
|
'products.form.aiPromptTooShort': 'Prompt must be at least 10 characters.',
|
||||||
|
'products.form.aiFillError': 'Unable to generate with AI.',
|
||||||
|
'products.form.aiFillNeedName': 'Enter a product name first.',
|
||||||
|
'products.form.aiGenerating': 'Generating…',
|
||||||
|
'products.form.aiRun': 'Run',
|
||||||
|
'products.form.aiSummaryFilled': 'Summary filled with AI.',
|
||||||
|
'products.form.aiDescriptionFilled': 'Description filled with AI.',
|
||||||
'products.form.images': 'Product Images',
|
'products.form.images': 'Product Images',
|
||||||
'products.form.imagesAdd': 'Add images',
|
'products.form.imagesAdd': 'Add images',
|
||||||
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
|
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
|
||||||
@@ -1356,6 +1368,18 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'products.form.summaryPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست محصولات',
|
'products.form.summaryPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست محصولات',
|
||||||
'products.form.description': 'توضیحات',
|
'products.form.description': 'توضیحات',
|
||||||
'products.form.descriptionPlaceholder': 'توضیحات کامل محصول با قالببندی...',
|
'products.form.descriptionPlaceholder': 'توضیحات کامل محصول با قالببندی...',
|
||||||
|
'products.form.aiFill': 'هوش مصنوعی',
|
||||||
|
'products.form.aiFillSummaryTitle': 'تولید خلاصه با هوش مصنوعی',
|
||||||
|
'products.form.aiFillDescriptionTitle': 'تولید توضیحات با هوش مصنوعی',
|
||||||
|
'products.form.aiFillSubtitle': 'پرامپت را ویرایش کنید، سپس اجرا کنید تا این فیلد پر شود.',
|
||||||
|
'products.form.aiPrompt': 'پرامپت',
|
||||||
|
'products.form.aiPromptTooShort': 'پرامپت باید حداقل ۱۰ کاراکتر باشد.',
|
||||||
|
'products.form.aiFillError': 'تولید با هوش مصنوعی ممکن نشد.',
|
||||||
|
'products.form.aiFillNeedName': 'ابتدا نام محصول را وارد کنید.',
|
||||||
|
'products.form.aiGenerating': 'در حال تولید…',
|
||||||
|
'products.form.aiRun': 'اجرا',
|
||||||
|
'products.form.aiSummaryFilled': 'خلاصه با هوش مصنوعی پر شد.',
|
||||||
|
'products.form.aiDescriptionFilled': 'توضیحات با هوش مصنوعی پر شد.',
|
||||||
'products.form.images': 'تصاویر محصول',
|
'products.form.images': 'تصاویر محصول',
|
||||||
'products.form.imagesAdd': 'افزودن تصاویر',
|
'products.form.imagesAdd': 'افزودن تصاویر',
|
||||||
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
|
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
|
||||||
|
|||||||
@@ -75,6 +75,36 @@
|
|||||||
|
|
||||||
.summaryField textarea {
|
.summaryField textarea {
|
||||||
min-height: calc((var(--field-height) + 8px) * 2);
|
min-height: calc((var(--field-height) + 8px) * 2);
|
||||||
|
padding-inline-end: 12px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldWithAi {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiFillAnchor {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
inset-inline-end: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiFillBtn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(67, 56, 202, 0.28),
|
||||||
|
0 0 16px rgba(236, 72, 153, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiFillBtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowSpan3 { grid-row: span 3; }
|
.rowSpan3 { grid-row: span 3; }
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { SearchableSelect } from '../components/SearchableSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
|
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
|
||||||
|
import { Tooltip } from '../components/Tooltip'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||||
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
|
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
|
||||||
@@ -19,9 +23,14 @@ import {
|
|||||||
mapProductApiToFormState,
|
mapProductApiToFormState,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
} from '../services/productService'
|
} from '../services/productService'
|
||||||
|
import {
|
||||||
|
fillProductFieldByAi,
|
||||||
|
type ProductAiFillField,
|
||||||
|
} from '../services/productAiService'
|
||||||
import type { Category, FlatCategory } from '../types/category'
|
import type { Category, FlatCategory } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
import { flattenCategories } from '../utils/categories'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
|
|
||||||
@@ -29,6 +38,7 @@ export function AddNewProductPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
const { showToast } = useToast()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
@@ -47,8 +57,50 @@ export function AddNewProductPage() {
|
|||||||
const [loaded, setLoaded] = useState(!isEdit)
|
const [loaded, setLoaded] = useState(!isEdit)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [aiField, setAiField] = useState<ProductAiFillField | null>(null)
|
||||||
|
const [isAiRunning, setIsAiRunning] = useState(false)
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const categoryOptions = flattenCategories(categories)
|
||||||
|
const selectedCategoryName = useMemo(() => {
|
||||||
|
const match = categoryOptions.find((item) => item.id === categoryId)
|
||||||
|
if (!match) return ''
|
||||||
|
return match.nameFa?.trim() || match.nameEn || ''
|
||||||
|
}, [categoryOptions, categoryId])
|
||||||
|
|
||||||
|
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
|
||||||
|
|
||||||
|
function openAiFill(field: ProductAiFillField) {
|
||||||
|
if (!nameFa.trim() && !nameEn.trim()) {
|
||||||
|
setError(t('products.form.aiFillNeedName'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setAiField(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAiFill(prompt: string) {
|
||||||
|
if (!aiField) return
|
||||||
|
setIsAiRunning(true)
|
||||||
|
try {
|
||||||
|
const result = await fillProductFieldByAi({
|
||||||
|
field: aiField,
|
||||||
|
prompt,
|
||||||
|
title: nameEn.trim() || undefined,
|
||||||
|
nameFa: nameFa.trim() || undefined,
|
||||||
|
categoryId: categoryId || undefined,
|
||||||
|
})
|
||||||
|
if (result.field === 'summary') {
|
||||||
|
setSummary(result.text)
|
||||||
|
showToast(t('products.form.aiSummaryFilled'), 'success')
|
||||||
|
} else {
|
||||||
|
setDescription(result.html)
|
||||||
|
showToast(t('products.form.aiDescriptionFilled'), 'success')
|
||||||
|
}
|
||||||
|
setAiField(null)
|
||||||
|
} finally {
|
||||||
|
setIsAiRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -264,27 +316,57 @@ export function AddNewProductPage() {
|
|||||||
|
|
||||||
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
|
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
|
||||||
<label htmlFor="summary">{t('products.form.summary')}</label>
|
<label htmlFor="summary">{t('products.form.summary')}</label>
|
||||||
<textarea
|
<div className={styles.fieldWithAi}>
|
||||||
id="summary"
|
<textarea
|
||||||
name="summary"
|
id="summary"
|
||||||
rows={4}
|
name="summary"
|
||||||
placeholder={t('products.form.summaryPlaceholder')}
|
rows={4}
|
||||||
value={summary}
|
placeholder={t('products.form.summaryPlaceholder')}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
value={summary}
|
||||||
required
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
disabled={isSubmitting}
|
required
|
||||||
/>
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span className={styles.aiFillAnchor}>
|
||||||
|
<Tooltip label={t('products.form.aiFill')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${aiStyles.aiBtn} ${styles.aiFillBtn}`}
|
||||||
|
onClick={() => openAiFill('summary')}
|
||||||
|
disabled={!canUseAiFill || isAiRunning}
|
||||||
|
aria-label={t('products.form.aiFill')}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
<label>{t('products.form.description')}</label>
|
<label>{t('products.form.description')}</label>
|
||||||
<RichTextEditor
|
<div className={styles.fieldWithAi}>
|
||||||
value={description}
|
<RichTextEditor
|
||||||
onChange={setDescription}
|
value={description}
|
||||||
placeholder={t('products.form.descriptionPlaceholder')}
|
onChange={setDescription}
|
||||||
/>
|
placeholder={t('products.form.descriptionPlaceholder')}
|
||||||
|
/>
|
||||||
|
<span className={styles.aiFillAnchor}>
|
||||||
|
<Tooltip label={t('products.form.aiFill')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${aiStyles.aiBtn} ${styles.aiFillBtn}`}
|
||||||
|
onClick={() => openAiFill('description')}
|
||||||
|
disabled={!canUseAiFill || isAiRunning}
|
||||||
|
aria-label={t('products.form.aiFill')}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
@@ -315,6 +397,19 @@ export function AddNewProductPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ProductFieldAiPromptModal
|
||||||
|
open={aiField !== null}
|
||||||
|
field={aiField ?? 'summary'}
|
||||||
|
nameFa={nameFa}
|
||||||
|
nameEn={nameEn}
|
||||||
|
categoryName={selectedCategoryName}
|
||||||
|
isRunning={isAiRunning}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isAiRunning) setAiField(null)
|
||||||
|
}}
|
||||||
|
onRun={runAiFill}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export function CustomersPage() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={filterStyles.filtersInputs}>
|
<div className={filterStyles.filtersInputs}>
|
||||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||||
<input
|
<input
|
||||||
@@ -267,9 +267,8 @@ export function CustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={t('customers.search')}
|
aria-label={t('customers.search')}
|
||||||
title={t('customers.search')}
|
title={t('customers.search')}
|
||||||
@@ -287,7 +286,7 @@ export function CustomersPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tablePanel}>
|
<div className={styles.tablePanel}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
import { Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
||||||
import { ProductCard } from '../components/ProductCard'
|
import { ProductCard } from '../components/ProductCard'
|
||||||
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
||||||
@@ -22,9 +22,19 @@ import filterStyles from '../components/ListFiltersPanel.module.css'
|
|||||||
import styles from './MyProductsPage.module.css'
|
import styles from './MyProductsPage.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
|
|
||||||
|
function readListQuery(searchParams: URLSearchParams) {
|
||||||
|
const name = searchParams.get('q')?.trim() ?? ''
|
||||||
|
const pageRaw = Number(searchParams.get('page'))
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1
|
||||||
|
return { name, page }
|
||||||
|
}
|
||||||
|
|
||||||
export function MyProductsPage() {
|
export function MyProductsPage() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
|
||||||
|
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
const [totalProducts, setTotalProducts] = useState(0)
|
const [totalProducts, setTotalProducts] = useState(0)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -32,7 +42,6 @@ export function MyProductsPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
|
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
|
||||||
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
||||||
const [variantsTarget, setVariantsTarget] = useState<{
|
const [variantsTarget, setVariantsTarget] = useState<{
|
||||||
id: string
|
id: string
|
||||||
@@ -46,17 +55,27 @@ export function MyProductsPage() {
|
|||||||
categoryId: string
|
categoryId: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||||
const [draftName, setDraftName] = useState('')
|
const [draftName, setDraftName] = useState(appliedName)
|
||||||
const [appliedName, setAppliedName] = useState('')
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftName(appliedName)
|
||||||
|
}, [appliedName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
void loadProducts(currentPage, controller.signal)
|
void loadProducts(currentPage, controller.signal)
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [currentPage, appliedName])
|
}, [currentPage, appliedName])
|
||||||
|
|
||||||
|
function writeListQuery(name: string, page: number) {
|
||||||
|
const next = new URLSearchParams()
|
||||||
|
if (name) next.set('q', name)
|
||||||
|
if (page > 1) next.set('page', String(page))
|
||||||
|
setSearchParams(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProducts(page: number, signal?: AbortSignal) {
|
async function loadProducts(page: number, signal?: AbortSignal) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -136,7 +155,7 @@ export function MyProductsPage() {
|
|||||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
|
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
|
||||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
setCurrentPage(nextPage)
|
writeListQuery(appliedName, nextPage)
|
||||||
await loadProducts(nextPage)
|
await loadProducts(nextPage)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -150,19 +169,17 @@ export function MyProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
setCurrentPage(page)
|
writeListQuery(appliedName, page)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
setAppliedName(draftName.trim())
|
writeListQuery(draftName.trim(), 1)
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setDraftName('')
|
setDraftName('')
|
||||||
setAppliedName('')
|
writeListQuery('', 1)
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommentCountChange(count: number) {
|
function handleCommentCountChange(count: number) {
|
||||||
@@ -193,7 +210,7 @@ export function MyProductsPage() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('products.list.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('products.list.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={filterStyles.filtersInputs}>
|
<div className={filterStyles.filtersInputs}>
|
||||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
||||||
<input
|
<input
|
||||||
@@ -208,9 +225,8 @@ export function MyProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label={t('products.list.search')}
|
aria-label={t('products.list.search')}
|
||||||
title={t('products.list.search')}
|
title={t('products.list.search')}
|
||||||
@@ -228,7 +244,7 @@ export function MyProductsPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export function OrdersPage() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={styles.ordersFiltersRow}>
|
<div className={styles.ordersFiltersRow}>
|
||||||
<div className={`${filterStyles.field} ${styles.filterOrderId}`}>
|
<div className={`${filterStyles.field} ${styles.filterOrderId}`}>
|
||||||
<input
|
<input
|
||||||
@@ -389,9 +389,8 @@ export function OrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={t('orders.search')}
|
aria-label={t('orders.search')}
|
||||||
title={t('orders.search')}
|
title={t('orders.search')}
|
||||||
@@ -409,7 +408,7 @@ export function OrdersPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tablePanel}>
|
<div className={styles.tablePanel}>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export function ShoppingCardsPage() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={styles.filtersRow}>
|
<div className={styles.filtersRow}>
|
||||||
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
|
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
|
||||||
<input
|
<input
|
||||||
@@ -268,9 +268,8 @@ export function ShoppingCardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={t('orders.search')}
|
aria-label={t('orders.search')}
|
||||||
title={t('orders.search')}
|
title={t('orders.search')}
|
||||||
@@ -288,7 +287,7 @@ export function ShoppingCardsPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={tableStyles.tablePanel}>
|
<div className={tableStyles.tablePanel}>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ function StoreItemsPageContent() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('storeItems.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('storeItems.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={filterStyles.filtersInputs}>
|
<div className={filterStyles.filtersInputs}>
|
||||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||||
<input
|
<input
|
||||||
@@ -298,9 +298,8 @@ function StoreItemsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label={t('storeItems.search')}
|
aria-label={t('storeItems.search')}
|
||||||
title={t('storeItems.search')}
|
title={t('storeItems.search')}
|
||||||
@@ -318,7 +317,7 @@ function StoreItemsPageContent() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function WebsiteContactPage() {
|
|||||||
|
|
||||||
<div className={filterStyles.filtersPanel}>
|
<div className={filterStyles.filtersPanel}>
|
||||||
<div className={filterStyles.filtersTitle}>{t('website.contact.filters')}</div>
|
<div className={filterStyles.filtersTitle}>{t('website.contact.filters')}</div>
|
||||||
<div className={filterStyles.filtersGrid}>
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={filterStyles.filtersInputs}>
|
<div className={filterStyles.filtersInputs}>
|
||||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
||||||
<input
|
<input
|
||||||
@@ -128,17 +128,13 @@ export function WebsiteContactPage() {
|
|||||||
onChange={(e) => setDraftQuery(e.target.value)}
|
onChange={(e) => setDraftQuery(e.target.value)}
|
||||||
placeholder={t('website.contact.searchPlaceholder')}
|
placeholder={t('website.contact.searchPlaceholder')}
|
||||||
aria-label={t('website.contact.search')}
|
aria-label={t('website.contact.search')}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') applyFilters()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={filterStyles.filterActions}>
|
<div className={filterStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={t('website.contact.search')}
|
aria-label={t('website.contact.search')}
|
||||||
title={t('website.contact.search')}
|
title={t('website.contact.search')}
|
||||||
@@ -156,7 +152,7 @@ export function WebsiteContactPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tablePanel}>
|
<div className={styles.tablePanel}>
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import type { ProductApi } from './productService'
|
|||||||
|
|
||||||
export type ProductAiLanguage = 'en' | 'fa'
|
export type ProductAiLanguage = 'en' | 'fa'
|
||||||
|
|
||||||
|
export type ProductAiFillField = 'summary' | 'description'
|
||||||
|
|
||||||
export interface CreateProductByAiPayload {
|
export interface CreateProductByAiPayload {
|
||||||
categoryId: string
|
categoryId: string
|
||||||
name: string
|
name: string
|
||||||
language: ProductAiLanguage
|
language: ProductAiLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FillProductFieldByAiPayload {
|
||||||
|
field: ProductAiFillField
|
||||||
|
prompt: string
|
||||||
|
title?: string
|
||||||
|
nameFa?: string
|
||||||
|
categoryId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProductByAi(payload: CreateProductByAiPayload) {
|
export async function createProductByAi(payload: CreateProductByAiPayload) {
|
||||||
const businessId = getActiveBusinessId()
|
const businessId = getActiveBusinessId()
|
||||||
if (!businessId) {
|
if (!businessId) {
|
||||||
@@ -25,3 +35,19 @@ export async function createProductByAi(payload: CreateProductByAiPayload) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fillProductFieldByAi(payload: FillProductFieldByAiPayload) {
|
||||||
|
const businessId = getActiveBusinessId()
|
||||||
|
if (!businessId) {
|
||||||
|
throw new Error('No active business selected. Please sign in again.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<
|
||||||
|
| { field: 'summary'; text: string }
|
||||||
|
| { field: 'description'; html: string }
|
||||||
|
>(`/businesses/${businessId}/products/ai-fill`, {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: repeat(12, 1fr);
|
||||||
gap: 10px 12px;
|
gap: 10px 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filtersInputs {
|
.filtersInputs {
|
||||||
|
|||||||
@@ -906,7 +906,7 @@ export function BusinessesPage() {
|
|||||||
<div className={styles.filtersPanel}>
|
<div className={styles.filtersPanel}>
|
||||||
<div className={styles.filtersTitle}>Filters</div>
|
<div className={styles.filtersTitle}>Filters</div>
|
||||||
|
|
||||||
<div className={styles.filtersGrid}>
|
<form className={styles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={styles.filtersInputs}>
|
<div className={styles.filtersInputs}>
|
||||||
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
<div className={`${styles.field} ${styles.fieldCol3}`}>
|
||||||
<label htmlFor="filter-name">Name</label>
|
<label htmlFor="filter-name">Name</label>
|
||||||
@@ -941,9 +941,8 @@ export function BusinessesPage() {
|
|||||||
|
|
||||||
<div className={styles.filterActions}>
|
<div className={styles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
|
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
title="Search"
|
title="Search"
|
||||||
@@ -961,7 +960,7 @@ export function BusinessesPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tablePanel}>
|
<div className={styles.tablePanel}>
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ export function UsersPage() {
|
|||||||
|
|
||||||
<div className={tableStyles.filtersPanel}>
|
<div className={tableStyles.filtersPanel}>
|
||||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||||
<div className={tableStyles.filtersGrid}>
|
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={tableStyles.filtersInputs}>
|
<div className={tableStyles.filtersInputs}>
|
||||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||||
<label htmlFor="filter-user-name">Name</label>
|
<label htmlFor="filter-user-name">Name</label>
|
||||||
@@ -536,9 +536,8 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={tableStyles.filterActions}>
|
<div className={tableStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
title="Search"
|
title="Search"
|
||||||
@@ -556,7 +555,7 @@ export function UsersPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={tableStyles.tablePanel}>
|
<div className={tableStyles.tablePanel}>
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export function WebsitesPage() {
|
|||||||
|
|
||||||
<div className={tableStyles.filtersPanel}>
|
<div className={tableStyles.filtersPanel}>
|
||||||
<div className={tableStyles.filtersTitle}>Filters</div>
|
<div className={tableStyles.filtersTitle}>Filters</div>
|
||||||
<div className={tableStyles.filtersGrid}>
|
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||||
<div className={tableStyles.filtersInputs}>
|
<div className={tableStyles.filtersInputs}>
|
||||||
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
||||||
<label htmlFor="filter-domain-name">Name</label>
|
<label htmlFor="filter-domain-name">Name</label>
|
||||||
@@ -400,9 +400,8 @@ export function WebsitesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={tableStyles.filterActions}>
|
<div className={tableStyles.filterActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
||||||
onClick={applyFilters}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
title="Search"
|
title="Search"
|
||||||
@@ -420,7 +419,7 @@ export function WebsitesPage() {
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={tableStyles.tablePanel}>
|
<div className={tableStyles.tablePanel}>
|
||||||
|
|||||||
Reference in New Issue
Block a user