Add product AI fill, Enter-to-filter, and URL-persisted product filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-08 16:35:14 +03:30
co-authored by Cursor
parent ad83fc1099
commit 4d480799b0
16 changed files with 524 additions and 65 deletions
@@ -21,6 +21,9 @@
grid-template-columns: repeat(12, 1fr);
gap: 8px 12px;
align-items: center;
margin: 0;
padding: 0;
border: none;
}
.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 (13 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.
- 24 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,
)
}
+24
View File
@@ -271,6 +271,18 @@ const en = {
'products.form.summaryPlaceholder': 'Short summary shown in product listings',
'products.form.description': 'Description',
'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.imagesAdd': 'Add images',
'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.description': 'توضیحات',
'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.imagesAdd': 'افزودن تصاویر',
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
@@ -75,6 +75,36 @@
.summaryField textarea {
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; }
+111 -16
View File
@@ -1,11 +1,15 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
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 { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
@@ -19,9 +23,14 @@ import {
mapProductApiToFormState,
updateProduct,
} from '../services/productService'
import {
fillProductFieldByAi,
type ProductAiFillField,
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
@@ -29,6 +38,7 @@ export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const t = useT()
const { showToast } = useToast()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
@@ -47,8 +57,50 @@ export function AddNewProductPage() {
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [aiField, setAiField] = useState<ProductAiFillField | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false)
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(() => {
const controller = new AbortController()
@@ -264,27 +316,57 @@ export function AddNewProductPage() {
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
<label htmlFor="summary">{t('products.form.summary')}</label>
<textarea
id="summary"
name="summary"
rows={4}
placeholder={t('products.form.summaryPlaceholder')}
value={summary}
onChange={(e) => setSummary(e.target.value)}
required
disabled={isSubmitting}
/>
<div className={styles.fieldWithAi}>
<textarea
id="summary"
name="summary"
rows={4}
placeholder={t('products.form.summaryPlaceholder')}
value={summary}
onChange={(e) => setSummary(e.target.value)}
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 className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.description')}</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder={t('products.form.descriptionPlaceholder')}
/>
<div className={styles.fieldWithAi}>
<RichTextEditor
value={description}
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 className={`${styles.field} ${styles.col12}`}>
@@ -315,6 +397,19 @@ export function AddNewProductPage() {
</button>
</div>
</form>
<ProductFieldAiPromptModal
open={aiField !== null}
field={aiField ?? 'summary'}
nameFa={nameFa}
nameEn={nameEn}
categoryName={selectedCategoryName}
isRunning={isAiRunning}
onClose={() => {
if (!isAiRunning) setAiField(null)
}}
onRun={runAiFill}
/>
</main>
)
}
+3 -4
View File
@@ -243,7 +243,7 @@ export function CustomersPage() {
<div className={filterStyles.filtersPanel}>
<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.field} ${filterStyles.fieldCol3}`}>
<input
@@ -267,9 +267,8 @@ export function CustomersPage() {
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label={t('customers.search')}
title={t('customers.search')}
@@ -287,7 +286,7 @@ export function CustomersPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={styles.tablePanel}>
+30 -14
View File
@@ -1,5 +1,5 @@
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 { ProductCard } from '../components/ProductCard'
import { AddProductByAiModal } from '../components/AddProductByAiModal'
@@ -22,9 +22,19 @@ import filterStyles from '../components/ListFiltersPanel.module.css'
import styles from './MyProductsPage.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() {
const t = useT()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
const [products, setProducts] = useState<Product[]>([])
const [totalProducts, setTotalProducts] = useState(0)
const [isLoading, setIsLoading] = useState(true)
@@ -32,7 +42,6 @@ export function MyProductsPage() {
const [error, setError] = useState('')
const [variantCounts, setVariantCounts] = 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 [variantsTarget, setVariantsTarget] = useState<{
id: string
@@ -46,17 +55,27 @@ export function MyProductsPage() {
categoryId: string
} | null>(null)
const [aiModalOpen, setAiModalOpen] = useState(false)
const [draftName, setDraftName] = useState('')
const [appliedName, setAppliedName] = useState('')
const [draftName, setDraftName] = useState(appliedName)
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
useEffect(() => {
setDraftName(appliedName)
}, [appliedName])
useEffect(() => {
const controller = new AbortController()
void loadProducts(currentPage, controller.signal)
return () => controller.abort()
}, [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) {
setIsLoading(true)
setError('')
@@ -136,7 +155,7 @@ export function MyProductsPage() {
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
writeListQuery(appliedName, nextPage)
await loadProducts(nextPage)
} catch (err) {
if (err instanceof ApiError) {
@@ -150,19 +169,17 @@ export function MyProductsPage() {
}
function handlePageChange(page: number) {
setCurrentPage(page)
writeListQuery(appliedName, page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function applyFilters() {
setAppliedName(draftName.trim())
setCurrentPage(1)
writeListQuery(draftName.trim(), 1)
}
function clearFilters() {
setDraftName('')
setAppliedName('')
setCurrentPage(1)
writeListQuery('', 1)
}
function handleCommentCountChange(count: number) {
@@ -193,7 +210,7 @@ export function MyProductsPage() {
<div className={filterStyles.filtersPanel}>
<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.field} ${filterStyles.fieldCol4}`}>
<input
@@ -208,9 +225,8 @@ export function MyProductsPage() {
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={isLoading}
aria-label={t('products.list.search')}
title={t('products.list.search')}
@@ -228,7 +244,7 @@ export function MyProductsPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
{error && (
+3 -4
View File
@@ -321,7 +321,7 @@ export function OrdersPage() {
<div className={filterStyles.filtersPanel}>
<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={`${filterStyles.field} ${styles.filterOrderId}`}>
<input
@@ -389,9 +389,8 @@ export function OrdersPage() {
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label={t('orders.search')}
title={t('orders.search')}
@@ -409,7 +408,7 @@ export function OrdersPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={styles.tablePanel}>
@@ -209,7 +209,7 @@ export function ShoppingCardsPage() {
<div className={filterStyles.filtersPanel}>
<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={`${filterStyles.field} ${styles.filterCustomer}`}>
<input
@@ -268,9 +268,8 @@ export function ShoppingCardsPage() {
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label={t('orders.search')}
title={t('orders.search')}
@@ -288,7 +287,7 @@ export function ShoppingCardsPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={tableStyles.tablePanel}>
+3 -4
View File
@@ -245,7 +245,7 @@ function StoreItemsPageContent() {
<div className={filterStyles.filtersPanel}>
<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.field} ${filterStyles.fieldCol3}`}>
<input
@@ -298,9 +298,8 @@ function StoreItemsPageContent() {
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={isLoading}
aria-label={t('storeItems.search')}
title={t('storeItems.search')}
@@ -318,7 +317,7 @@ function StoreItemsPageContent() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
{error && <p className={styles.error}>{error}</p>}
@@ -119,7 +119,7 @@ export function WebsiteContactPage() {
<div className={filterStyles.filtersPanel}>
<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.field} ${filterStyles.fieldCol4}`}>
<input
@@ -128,17 +128,13 @@ export function WebsiteContactPage() {
onChange={(e) => setDraftQuery(e.target.value)}
placeholder={t('website.contact.searchPlaceholder')}
aria-label={t('website.contact.search')}
onKeyDown={(e) => {
if (e.key === 'Enter') applyFilters()
}}
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label={t('website.contact.search')}
title={t('website.contact.search')}
@@ -156,7 +152,7 @@ export function WebsiteContactPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={styles.tablePanel}>
@@ -4,12 +4,22 @@ import type { ProductApi } from './productService'
export type ProductAiLanguage = 'en' | 'fa'
export type ProductAiFillField = 'summary' | 'description'
export interface CreateProductByAiPayload {
categoryId: string
name: string
language: ProductAiLanguage
}
export interface FillProductFieldByAiPayload {
field: ProductAiFillField
prompt: string
title?: string
nameFa?: string
categoryId?: string
}
export async function createProductByAi(payload: CreateProductByAiPayload) {
const businessId = getActiveBusinessId()
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);
gap: 10px 12px;
align-items: end;
margin: 0;
padding: 0;
border: none;
}
.filtersInputs {
@@ -906,7 +906,7 @@ export function BusinessesPage() {
<div className={styles.filtersPanel}>
<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.field} ${styles.fieldCol3}`}>
<label htmlFor="filter-name">Name</label>
@@ -941,9 +941,8 @@ export function BusinessesPage() {
<div className={styles.filterActions}>
<button
type="button"
type="submit"
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
@@ -961,7 +960,7 @@ export function BusinessesPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={styles.tablePanel}>
+3 -4
View File
@@ -497,7 +497,7 @@ export function UsersPage() {
<div className={tableStyles.filtersPanel}>
<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.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-user-name">Name</label>
@@ -536,9 +536,8 @@ export function UsersPage() {
</div>
<div className={tableStyles.filterActions}>
<button
type="button"
type="submit"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
@@ -556,7 +555,7 @@ export function UsersPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={tableStyles.tablePanel}>
+3 -4
View File
@@ -386,7 +386,7 @@ export function WebsitesPage() {
<div className={tableStyles.filtersPanel}>
<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.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-domain-name">Name</label>
@@ -400,9 +400,8 @@ export function WebsitesPage() {
</div>
<div className={tableStyles.filterActions}>
<button
type="button"
type="submit"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
@@ -420,7 +419,7 @@ export function WebsitesPage() {
<RotateCcw size={18} />
</button>
</div>
</div>
</form>
</div>
<div className={tableStyles.tablePanel}>