From 652f4d07b67b400a021ed0bb4601943267dbadde Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 16 Sep 2026 17:33:44 +0330 Subject: [PATCH] Show other-pages stats and store-specials AI matching. Add the Statistics pages row for static_page views, wire specials AI match UI, and tidy Businesses page styles. Co-authored-by: Cursor --- .../components/SpecialItemsAiModal.module.css | 238 ++++++++++ .../src/components/SpecialItemsAiModal.tsx | 406 ++++++++++++++++++ .../src/components/StoreSpecialCarousel.tsx | 14 +- apps/business/src/i18n/messages.ts | 65 +++ apps/business/src/pages/StatisticsPage.tsx | 1 + apps/business/src/pages/StoreSpecialsPage.tsx | 32 ++ .../src/services/dailyActivityService.ts | 2 + .../src/services/storeSpecialService.ts | 35 ++ apps/business/src/utils/specialItemsAi.ts | 397 +++++++++++++++++ .../src/pages/BusinessesPage.module.css | 19 +- apps/super-admin/src/pages/BusinessesPage.tsx | 8 +- 11 files changed, 1195 insertions(+), 22 deletions(-) create mode 100644 apps/business/src/components/SpecialItemsAiModal.module.css create mode 100644 apps/business/src/components/SpecialItemsAiModal.tsx create mode 100644 apps/business/src/utils/specialItemsAi.ts diff --git a/apps/business/src/components/SpecialItemsAiModal.module.css b/apps/business/src/components/SpecialItemsAiModal.module.css new file mode 100644 index 0000000..403afdb --- /dev/null +++ b/apps/business/src/components/SpecialItemsAiModal.module.css @@ -0,0 +1,238 @@ +.modal { + max-width: 640px; +} + +.header { + align-items: flex-start; +} + +.headerBlock { + flex: 1; + min-width: 0; +} + +.subtitle { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; + line-height: 1.45; +} + +.body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.listArea { + min-height: 180px; + resize: vertical; + font-family: var(--font-ui); + font-size: var(--field-font-size); + line-height: 1.55; +} + +.checkRow { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + font-family: var(--font-ui); + cursor: pointer; + user-select: none; +} + +.checkRow input { + width: 16px; + height: 16px; + accent-color: var(--primary); + cursor: pointer; +} + +.reviewHint { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.reviewList { + display: flex; + flex-direction: column; + gap: 8px; + max-height: min(48vh, 420px); + overflow-y: auto; + padding-inline-end: 2px; +} + +.reviewCard { + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.22); + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.reviewCardMiss { + border-color: rgba(245, 158, 11, 0.3); + background: rgba(120, 53, 15, 0.2); +} + +.reviewTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.lineIndex { + font-weight: 600; + color: rgba(226, 232, 240, 0.78); +} + +.score { + font-size: 12px; + font-weight: 700; + color: #67e8f9; + font-variant-numeric: tabular-nums; +} + +.rawLine { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: rgba(226, 232, 240, 0.72); + font-family: var(--font-ui); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.matchBlock { + display: flex; + gap: 10px; + align-items: center; +} + +.thumb, +.thumbPlaceholder { + width: 40px; + height: 40px; + border-radius: 8px; + flex-shrink: 0; + object-fit: cover; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.14); +} + +.matchText { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; +} + +.matchTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #f8fafc; + font-family: var(--font-ui); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.matchMeta { + margin: 0; + font-size: 11px; + color: rgba(191, 219, 254, 0.72); + font-family: var(--font-ui); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.priceRow { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-family: var(--font-ui); + font-size: 11px; + line-height: 1.35; +} + +.priceLabel { + color: rgba(148, 163, 184, 0.92); +} + +.priceCurrent { + color: rgba(226, 232, 240, 0.72); +} + +.priceArrow { + color: rgba(103, 232, 249, 0.88); + font-weight: 700; +} + +.priceNext { + color: #67e8f9; + font-weight: 700; +} + +.noMatch { + margin: 0; + font-size: 12px; + font-weight: 600; + color: #f59e0b; +} + +.error { + margin: 0; + padding: 10px 12px; + font-size: 13px; + color: #fecaca; + background: rgba(127, 29, 29, 0.35); + border: 1px solid rgba(248, 113, 113, 0.28); + border-radius: var(--radius-sm); +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 4px; + direction: rtl; +} + +.cancelBtn { + height: var(--field-height); + padding: 0 18px; + font-size: var(--field-font-size); + font-weight: 500; + font-family: var(--font-ui); + border-radius: var(--radius-sm); + transition: background 0.2s, color 0.2s, opacity 0.2s; + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.12); +} + +.cancelBtn:hover:not(:disabled) { + background: rgba(148, 163, 184, 0.2); +} + +.cancelBtn:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/apps/business/src/components/SpecialItemsAiModal.tsx b/apps/business/src/components/SpecialItemsAiModal.tsx new file mode 100644 index 0000000..1d58c44 --- /dev/null +++ b/apps/business/src/components/SpecialItemsAiModal.tsx @@ -0,0 +1,406 @@ +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Sparkles, X } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import { ApiError } from '../lib/api' +import { useT } from '../i18n/useT' +import { updateStoreItem } from '../services/storeItemService' +import { + matchStoreSpecialListWithAi, + updateStoreSpecial, + type SpecialItemsAiMatchRow, +} from '../services/storeSpecialService' +import type { SpecialProductsSource } from '../services/settingsService' +import type { StoreSpecial } from '../types/storeSpecial' +import { formatIrtPrice } from '../utils/irtPrice' +import modalStyles from './VariationsModal.module.css' +import aiStyles from '../styles/ai.module.css' +import styles from './SpecialItemsAiModal.module.css' + +type Step = 'input' | 'review' + +const ANIMATION_MS = 220 + +export interface SpecialItemsAiApplyResult { + special: StoreSpecial + added: number + priced: number + cleared: boolean +} + +interface SpecialItemsAiModalProps { + open: boolean + special: StoreSpecial | null + source: SpecialProductsSource + onClose: () => void + onApplied: (result: SpecialItemsAiApplyResult) => void +} + +export function SpecialItemsAiModal({ + open, + special, + source, + onClose, + onApplied, +}: SpecialItemsAiModalProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const fromProducts = source === 'product' + + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [step, setStep] = useState('input') + const [listText, setListText] = useState('') + const [replaceExisting, setReplaceExisting] = useState(false) + const [rows, setRows] = useState([]) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setStep('input') + setListText('') + setReplaceExisting(false) + setRows([]) + setError('') + setBusy(false) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, busy, onClose]) + + const selectedCount = useMemo( + () => rows.filter((row) => row.selected && row.candidate).length, + [rows], + ) + + async function handleMatch() { + if (!special) return + if (listText.trim().length < 3) { + setError(t('website.specialItems.ai.listEmpty')) + return + } + + setBusy(true) + setError('') + + try { + const result = await matchStoreSpecialListWithAi(listText) + setRows(result.matches) + setStep('review') + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('website.specialItems.ai.errorMatch')) + } + } finally { + setBusy(false) + } + } + + async function handleApply() { + if (!special) return + const accepted = rows.filter((row) => row.selected && row.candidate) + if (accepted.length === 0) { + setError(t('website.specialItems.ai.noneSelected')) + return + } + + setBusy(true) + setError('') + + try { + let priced = 0 + + if (!fromProducts) { + for (const row of accepted) { + if (!row.updatePrice || row.price == null || !row.candidate) continue + await updateStoreItem(row.candidate.variantId, { price: row.price }) + priced += 1 + } + } + + const existingIds = special.items.map((item) => + fromProducts ? item.productId : item.id, + ) + const matchedIds = accepted.map((row) => + fromProducts ? row.candidate!.productId : row.candidate!.storeItemId, + ) + const nextIds = replaceExisting + ? [...new Set(matchedIds)] + : [...new Set([...existingIds, ...matchedIds])] + + const result = await updateStoreSpecial( + special.id, + fromProducts ? { productIds: nextIds } : { storeItemIds: nextIds }, + ) + + onApplied({ + special: result.special, + added: matchedIds.length, + priced, + cleared: replaceExisting, + }) + onClose() + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('website.specialItems.ai.errorApply')) + } + } finally { + setBusy(false) + } + } + + function toggleRow(index: number) { + setRows((prev) => + prev.map((row, i) => + i === index && row.candidate ? { ...row, selected: !row.selected } : row, + ), + ) + } + + function togglePrice(index: number) { + setRows((prev) => + prev.map((row, i) => (i === index ? { ...row, updatePrice: !row.updatePrice } : row)), + ) + } + + if (!mounted || !special) return null + + return createPortal( +
e.target === e.currentTarget && !busy && onClose()} + role="presentation" + > +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > +
+
+

+ {t('website.specialItems.ai.title')} +

+

+ {t('website.specialItems.ai.subtitle', { title: special.title })} +

+
+ +
+ +
+ {error &&

{error}

} + + {step === 'input' ? ( + <> +
+ +