From 77080a7a4bf514236772598cb3b6d5099069d0fd Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 22 Aug 2026 20:38:58 +0330 Subject: [PATCH] Improve website settings and special-item pick UX. Tighten sitemap/settings controls, debounce product search to 10 results, keep catalog-only specials visible after add, and use frosted glass on link/category modals. Co-authored-by: Cursor --- .../src/components/CategoryModal.module.css | 8 +- .../GenerateWebsiteLinkModal.module.css | 5 + .../components/PickStoreSpecialItemsModal.tsx | 221 ++++++++++-------- .../src/components/PickWebsiteBrandsModal.tsx | 2 + .../components/PickWebsiteCategoriesModal.tsx | 2 + .../business/src/components/StoreItemCard.tsx | 99 ++++---- .../components/StoreItemMultiSearchSelect.tsx | 126 +++++++--- apps/business/src/i18n/messages.ts | 24 +- apps/business/src/pages/StoreSpecialsPage.tsx | 11 +- .../src/pages/WebsiteSettingsPage.module.css | 113 ++++++--- .../src/pages/WebsiteSettingsPage.tsx | 92 +++----- .../business/src/services/storeItemService.ts | 17 +- .../src/utils/storeSpecialListings.ts | 66 +++++- 13 files changed, 490 insertions(+), 296 deletions(-) diff --git a/apps/business/src/components/CategoryModal.module.css b/apps/business/src/components/CategoryModal.module.css index fcca78c..4fd9b64 100644 --- a/apps/business/src/components/CategoryModal.module.css +++ b/apps/business/src/components/CategoryModal.module.css @@ -45,12 +45,12 @@ .modal { width: 100%; max-width: 480px; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); + background: var(--glass-bg); + backdrop-filter: blur(var(--blur-glass, 28px)) saturate(1.45); + -webkit-backdrop-filter: blur(var(--blur-glass, 28px)) saturate(1.45); border: 1px solid var(--glass-border); border-radius: var(--radius); - box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15); + box-shadow: var(--glass-shadow), 0 20px 60px rgba(31, 38, 135, 0.12); } .header { diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.module.css b/apps/business/src/components/GenerateWebsiteLinkModal.module.css index 0e4cf21..73b8f32 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.module.css +++ b/apps/business/src/components/GenerateWebsiteLinkModal.module.css @@ -4,6 +4,11 @@ display: flex; flex-direction: column; overflow: hidden; + background: var(--glass-bg); + backdrop-filter: blur(var(--blur-glass, 28px)) saturate(1.45); + -webkit-backdrop-filter: blur(var(--blur-glass, 28px)) saturate(1.45); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow), 0 20px 60px rgba(31, 38, 135, 0.12); } .header { diff --git a/apps/business/src/components/PickStoreSpecialItemsModal.tsx b/apps/business/src/components/PickStoreSpecialItemsModal.tsx index 7b05f66..ee6091f 100644 --- a/apps/business/src/components/PickStoreSpecialItemsModal.tsx +++ b/apps/business/src/components/PickStoreSpecialItemsModal.tsx @@ -1,14 +1,15 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' -import { listAllProducts } from '../services/productService' -import { listAllStoreItems } from '../services/storeItemService' +import { listProducts } from '../services/productService' +import { listStoreItems } from '../services/storeItemService' import type { SpecialProductsSource } from '../services/settingsService' import { groupStoreItemsByProduct } from '../utils/storeProductGroups' import { + SEARCH_RESULT_LIMIT, StoreItemMultiSearchSelect, type StoreItemSearchOption, } from './StoreItemMultiSearchSelect' @@ -22,11 +23,12 @@ interface PickStoreSpecialItemsModalProps { existingIds: string[] source: SpecialProductsSource onClose: () => void - onConfirm: (ids: string[]) => void + onConfirm: (ids: string[]) => void | Promise isSubmitting?: boolean } const ANIMATION_MS = 220 +const STORE_ITEM_SEARCH_PAGE_SIZE = 30 export function PickStoreSpecialItemsModal({ open, @@ -44,10 +46,10 @@ export function PickStoreSpecialItemsModal({ const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [options, setOptions] = useState([]) - const [isLoading, setIsLoading] = useState(false) + const [isSearching, setIsSearching] = useState(false) const [error, setError] = useState('') const [selectedIds, setSelectedIds] = useState([]) - const [selectOpen, setSelectOpen] = useState(false) + const searchControllerRef = useRef(null) const existingSet = useMemo(() => new Set(existingIds), [existingIds]) @@ -56,7 +58,9 @@ export function PickStoreSpecialItemsModal({ setMounted(true) setClosing(false) setSelectedIds([]) + setOptions([]) setError('') + setIsSearching(false) } else if (mounted) { setClosing(true) const timer = setTimeout(() => { @@ -68,12 +72,10 @@ export function PickStoreSpecialItemsModal({ }, [open, mounted]) useEffect(() => { - if (!open) return - - const controller = new AbortController() - void loadOptions(controller.signal) - return () => controller.abort() - }, [open, existingIds, source]) + return () => { + searchControllerRef.current?.abort() + } + }, []) useEffect(() => { if (!mounted || closing) return @@ -84,69 +86,47 @@ export function PickStoreSpecialItemsModal({ return () => document.removeEventListener('keydown', onKey) }, [mounted, closing, onClose]) - async function loadOptions(signal?: AbortSignal) { - setIsLoading(true) - setError('') + const handleSearch = useCallback( + async (query: string) => { + searchControllerRef.current?.abort() - try { - const nextOptions = fromProducts - ? await loadProductOptions(signal) - : await loadStoreItemOptions(signal) - - setOptions(nextOptions.filter((option) => !existingSet.has(option.id))) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - if (err instanceof ApiError) { - setError(err.message) - } else { - setError( - fromProducts - ? t('website.specialItems.pickErrorLoadProducts') - : t('website.specialItems.pickErrorLoad'), - ) + if (!query) { + setOptions([]) + setIsSearching(false) + setError('') + return } - } finally { - setIsLoading(false) - } - } - async function loadStoreItemOptions(signal?: AbortSignal) { - const items = await listAllStoreItems(signal) - const listings = groupStoreItemsByProduct(items) - return listings - .map((listing): StoreItemSearchOption | null => { - const storeItemId = listing.representative.storeItemId ?? listing.representative.id - const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null - const price = firstPricedVariant?.price ?? null - const hasPrice = listing.variants.some((v) => v.price !== null) - const hasInStock = listing.variants.some( - (v) => v.stockQuantity === null || v.stockQuantity > 0, - ) + const controller = new AbortController() + searchControllerRef.current = controller + setIsSearching(true) + setError('') - if (!hasPrice && !hasInStock) return null + try { + const nextOptions = fromProducts + ? await loadProductOptions(query, controller.signal) + : await loadStoreItemOptions(query, controller.signal) - return { - id: storeItemId, - title: listing.productTitle, - nameFa: listing.productNameFa, - image: listing.productImage, - price, + if (controller.signal.aborted) return + setOptions(nextOptions.filter((option) => !existingSet.has(option.id))) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (controller.signal.aborted) return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError( + fromProducts + ? t('website.specialItems.pickErrorLoadProducts') + : t('website.specialItems.pickErrorLoad'), + ) } - }) - .filter((option): option is StoreItemSearchOption => option !== null) - } - - async function loadProductOptions(signal?: AbortSignal) { - const products = await listAllProducts(signal) - return products.map( - (product): StoreItemSearchOption => ({ - id: product.id, - title: product.title, - nameFa: product.nameFa, - image: product.thumbnailUrl || product.thumbnail || product.image || null, - }), - ) - } + } finally { + if (!controller.signal.aborted) setIsSearching(false) + } + }, + [existingSet, fromProducts, t], + ) function toggleSelection(id: string) { setSelectedIds((prev) => @@ -154,9 +134,22 @@ export function PickStoreSpecialItemsModal({ ) } - function handleConfirm() { - if (selectedIds.length === 0) return - onConfirm(selectedIds) + async function handleConfirm() { + if (selectedIds.length === 0 || isSubmitting) return + setError('') + try { + await onConfirm(selectedIds) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else if (err instanceof Error && err.message) { + setError(err.message) + } else { + setError( + t('website.specialItems.errorAdd'), + ) + } + } } function confirmLabel() { @@ -172,7 +165,7 @@ export function PickStoreSpecialItemsModal({ className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} onClick={(e) => { if (e.target !== e.currentTarget) return - if (selectOpen) return + if (selectedIds.length > 0) return onClose() }} role="presentation" @@ -184,6 +177,8 @@ export function PickStoreSpecialItemsModal({ aria-labelledby="pick-special-items-title" lang={isFa ? 'fa' : 'en'} dir={isFa ? 'rtl' : 'ltr'} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} >
@@ -209,32 +204,19 @@ export function PickStoreSpecialItemsModal({
{error &&

{error}

} - {isLoading ? ( -

- {fromProducts - ? t('website.specialItems.pickLoadingProducts') - : t('website.specialItems.pickLoading')} -

- ) : options.length === 0 ? ( -

- {fromProducts - ? t('website.specialItems.pickEmptyProducts') - : t('website.specialItems.pickEmpty')} -

- ) : ( - - )} + void handleSearch(query)} + isSearching={isSearching} + placeholder={ + fromProducts + ? t('website.specialItems.pickSearchProducts') + : t('website.specialItems.pickSearch') + } + disabled={isSubmitting} + />
- - - - - - - + {listing.variantCount > 0 ? ( + <> + + + + + + + + + + + ) : null}
- - - + {listing.variantCount > 0 ? ( + + + + ) : null}
) diff --git a/apps/business/src/components/StoreItemMultiSearchSelect.tsx b/apps/business/src/components/StoreItemMultiSearchSelect.tsx index 8248d35..b6c9831 100644 --- a/apps/business/src/components/StoreItemMultiSearchSelect.tsx +++ b/apps/business/src/components/StoreItemMultiSearchSelect.tsx @@ -6,6 +6,9 @@ import { formatIrtPrice } from '../utils/irtPrice' import selectStyles from './SearchableSelect.module.css' import styles from './StoreItemMultiSearchSelect.module.css' +export const SEARCH_RESULT_LIMIT = 10 +export const SEARCH_DEBOUNCE_MS = 1000 + export interface StoreItemSearchOption { id: string title: string @@ -25,6 +28,14 @@ interface StoreItemMultiSearchSelectProps { placeholder?: string disabled?: boolean minSearchLength?: number + maxResults?: number + isSearching?: boolean + /** + * When set, typing is debounced and the parent fetches results. + * Local filtering is skipped. + */ + onQueryChange?: (query: string) => void + debounceMs?: number /** * Optional hook for parents that need to know when the dropdown is open * (e.g. to prevent modal outside-click closing). @@ -39,6 +50,10 @@ export function StoreItemMultiSearchSelect({ placeholder, disabled = false, minSearchLength = 3, + maxResults = SEARCH_RESULT_LIMIT, + isSearching = false, + onQueryChange, + debounceMs = SEARCH_DEBOUNCE_MS, onOpenChange, }: StoreItemMultiSearchSelectProps) { const t = useT() @@ -46,47 +61,74 @@ export function StoreItemMultiSearchSelect({ const isFa = locale === 'fa' const [open, setOpen] = useState(false) const [query, setQuery] = useState('') - const containerRef = useRef(null) + const [selectedById, setSelectedById] = useState>({}) const inputRef = useRef(null) + const onQueryChangeRef = useRef(onQueryChange) + onQueryChangeRef.current = onQueryChange const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength }) + const trimmedQuery = query.trim() + const canSearch = trimmedQuery.length >= minSearchLength useEffect(() => { onOpenChange?.(open) }, [open, onOpenChange]) - const filtered = useMemo(() => { - const trimmed = query.trim() - if (trimmed.length < minSearchLength) return [] - const q = trimmed.toLowerCase() - return options.filter( - (option) => - option.title.toLowerCase().includes(q) || option.nameFa.includes(trimmed), - ) - }, [options, query, minSearchLength]) - - const selectedOptions = useMemo( - () => options.filter((option) => selectedSet.has(option.id)), - [options, selectedSet], - ) + useEffect(() => { + setSelectedById((prev) => { + const next = { ...prev } + for (const id of Object.keys(next)) { + if (!selectedSet.has(id)) delete next[id] + } + for (const option of options) { + if (selectedSet.has(option.id)) next[option.id] = option + } + return next + }) + }, [options, selectedSet]) useEffect(() => { - if (!open) return - const onClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) - setQuery('') - } + const cb = onQueryChangeRef.current + if (!cb) return + if (!canSearch) { + cb('') + return } - document.addEventListener('mousedown', onClickOutside) - return () => document.removeEventListener('mousedown', onClickOutside) - }, [open]) + const timer = window.setTimeout(() => cb(trimmedQuery), debounceMs) + return () => window.clearTimeout(timer) + }, [canSearch, trimmedQuery, debounceMs]) + + const filtered = useMemo(() => { + if (!canSearch) return [] + if (onQueryChange) return options.slice(0, maxResults) + const q = trimmedQuery.toLowerCase() + return options + .filter( + (option) => + option.title.toLowerCase().includes(q) || option.nameFa.includes(trimmedQuery), + ) + .slice(0, maxResults) + }, [canSearch, onQueryChange, options, trimmedQuery, maxResults]) + + const selectedOptions = useMemo( + () => + selectedIds + .map((id) => selectedById[id]) + .filter((option): option is StoreItemSearchOption => Boolean(option)), + [selectedIds, selectedById], + ) function handleFocus() { if (!disabled) setOpen(true) } + function handleClearSearch() { + setQuery('') + setOpen(false) + onQueryChangeRef.current?.('') + } + function renderOptionMeta(option: StoreItemSearchOption) { const primary = isFa ? option.nameFa || option.title : option.title const secondary = isFa @@ -121,11 +163,11 @@ export function StoreItemMultiSearchSelect({ ) } - const showDropdown = open && query.trim().length >= minSearchLength + const showDropdown = open && (canSearch || trimmedQuery.length > 0) return (
-
+
+ {query ? ( + + ) : null}
{showDropdown && (
    - {filtered.length === 0 ? ( + {!canSearch ? (
  • - {options.length === 0 - ? t('storeItems.search.noProducts') - : t('storeItems.search.noMatch')} + {t('storeItems.search.minChars', { count: minSearchLength })} +
  • + ) : isSearching ? ( +
  • {t('website.specialItems.pickSearching')}
  • + ) : filtered.length === 0 ? ( +
  • + {onQueryChange || options.length > 0 + ? t('storeItems.search.noMatch') + : t('storeItems.search.noProducts')}
  • ) : ( filtered.map((option) => { @@ -182,14 +240,6 @@ export function StoreItemMultiSearchSelect({ )}
)} - - {open && query.trim().length > 0 && query.trim().length < minSearchLength && ( -
    -
  • - {t('storeItems.search.minChars', { count: minSearchLength })} -
  • -
- )}
{selectedOptions.length > 0 && ( diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 8fe0c1e..6726a36 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -797,6 +797,7 @@ const en = { 'storeItems.edit.saving': 'Saving…', 'storeItems.edit.errorSave': 'Unable to save store items.', 'storeItems.search.clear': 'Clear selection', + 'storeItems.search.clearQuery': 'Clear search', 'storeItems.search.noProducts': 'No products available.', 'storeItems.search.noMatch': 'No matching products.', 'storeItems.search.minChars': 'Type at least {count} characters to search', @@ -1692,6 +1693,7 @@ const en = { 'website.specialItems.pickSubtitle': 'Select store items to feature in this category.', 'website.specialItems.pickSubtitleProducts': 'Select products to feature in this category.', + 'website.specialItems.pickSearching': 'Searching...', 'website.specialItems.pickLoading': 'Loading store items...', 'website.specialItems.pickLoadingProducts': 'Loading products...', 'website.specialItems.pickEmpty': 'All store items are already in this category.', @@ -1712,12 +1714,12 @@ const en = { 'Choose where your special products come from: items added to the store for sale, or catalog products (without price and stock).', 'website.settings.source.product': 'Products', 'website.settings.source.storeItem': 'Store', - 'website.settings.sitemapTitle': 'Sitemap routes', + 'website.settings.sitemapTitle': 'Sitemap settings', 'website.settings.sitemapDesc': - 'Import static pages and URL templates from GET /meshkee/sitemap-config.json on your live website. The API uses this with published CMS content to build /sitemap.xml.', - 'website.settings.sitemapSync': 'Sync sitemap config from website', - 'website.settings.sitemapSynced': 'Sitemap config synced from the website.', - 'website.settings.sitemapSyncError': 'Unable to sync sitemap config from the website.', + 'Click the button on the left to rebuild the sitemap. Do this only after adding a static page that should appear in the sitemap. Dynamic pages such as products, articles, and portfolios are added automatically.', + 'website.settings.sitemapSync': 'Rebuild sitemap', + 'website.settings.sitemapSynced': 'Sitemap rebuilt.', + 'website.settings.sitemapSyncError': 'Unable to rebuild the sitemap.', 'website.settings.sitemapNotSynced': 'Not synced yet — publish /meshkee/sitemap-config.json on the site, then sync.', 'website.settings.sitemapImported': 'Imported from website', 'website.settings.sitemapSyncedAt': 'Last synced {date}', @@ -2958,6 +2960,7 @@ const fa: Record = { 'storeItems.edit.saving': 'در حال ذخیره…', 'storeItems.edit.errorSave': 'ذخیره محصول فروشگاه ممکن نشد.', 'storeItems.search.clear': 'پاک کردن انتخاب', + 'storeItems.search.clearQuery': 'پاک کردن جستجو', 'storeItems.search.noProducts': 'محصولی موجود نیست.', 'storeItems.search.noMatch': 'محصولی یافت نشد.', 'storeItems.search.minChars': 'حداقل {count} کاراکتر برای جستجو وارد کنید', @@ -3852,6 +3855,7 @@ const fa: Record = { 'website.specialItems.pickSubtitle': 'محصولات فروشگاه را برای نمایش در این دسته انتخاب کنید.', 'website.specialItems.pickSubtitleProducts': 'محصولات را برای نمایش در این دسته انتخاب کنید.', + 'website.specialItems.pickSearching': 'در حال جستجو...', 'website.specialItems.pickLoading': 'در حال بارگذاری محصولات فروشگاه...', 'website.specialItems.pickLoadingProducts': 'در حال بارگذاری محصولات...', 'website.specialItems.pickEmpty': 'همه محصولات فروشگاه قبلاً در این دسته هستند.', @@ -3872,12 +3876,12 @@ const fa: Record = { 'مشخص کنید محصولات ویژه ی شما از کدام بخش خوانده شود: محصولاتی که برای فروش به فروشگاه اضافه شده است و یا خود محصولات (بدون قیمت و موجودی)', 'website.settings.source.product': 'محصولات', 'website.settings.source.storeItem': 'فروشگاه', - 'website.settings.sitemapTitle': 'مسیرهای sitemap', + 'website.settings.sitemapTitle': 'تنظیمات سایت مپ', 'website.settings.sitemapDesc': - 'صفحات ثابت و الگوی URL را از GET /meshkee/sitemap-config.json روی وب‌سایت زنده import کنید. API با محتوای منتشرشده CMS، /sitemap.xml را می‌سازد.', - 'website.settings.sitemapSync': 'همگام‌سازی تنظیمات sitemap از وب‌سایت', - 'website.settings.sitemapSynced': 'تنظیمات sitemap از وب‌سایت همگام شد.', - 'website.settings.sitemapSyncError': 'همگام‌سازی تنظیمات sitemap از وب‌سایت ممکن نشد.', + 'با زدن دکمه ی روبرو، سایت مپ مجدد ساخته خواهد شد. این کار را فقط وقتی انجام دهید که یک صفحه ی استاتیک به وبسایت اضافه کردید و میخواهید در سایت مپ قرار گیرد. صفحات داینامیک مانند محصولات، مطالب و نمونه کارها به صورت اتوماتیک به سایت مپ اضافه خواهند شد.', + 'website.settings.sitemapSync': 'بازسازی سایت مپ', + 'website.settings.sitemapSynced': 'سایت مپ ساخته شد.', + 'website.settings.sitemapSyncError': 'ساخت مجدد سایت مپ ممکن نشد.', 'website.settings.sitemapNotSynced': 'هنوز همگام نشده — /meshkee/sitemap-config.json را روی سایت منتشر کنید، سپس همگام کنید.', 'website.settings.sitemapImported': 'از وب‌سایت import شده', diff --git a/apps/business/src/pages/StoreSpecialsPage.tsx b/apps/business/src/pages/StoreSpecialsPage.tsx index bb92934..57dac2e 100644 --- a/apps/business/src/pages/StoreSpecialsPage.tsx +++ b/apps/business/src/pages/StoreSpecialsPage.tsx @@ -262,7 +262,7 @@ export function StoreSpecialsPage() { const existingIds = pickItemsTarget.items.map((item) => fromProducts ? item.productId : item.id, ) - const nextIds = [...existingIds, ...ids] + const nextIds = [...new Set([...existingIds, ...ids])] setIsSaving(true) setError('') @@ -276,11 +276,10 @@ export function StoreSpecialsPage() { setPickItemsTarget(null) showToast(t('website.specialItems.toastAdded'), 'success') } catch (err) { - if (err instanceof ApiError) { - setError(err.message) - } else { - setError(t('website.specialItems.errorAdd')) - } + const message = + err instanceof ApiError ? err.message : t('website.specialItems.errorAdd') + setError(message) + throw err instanceof Error ? err : new Error(message) } finally { setIsSaving(false) } diff --git a/apps/business/src/pages/WebsiteSettingsPage.module.css b/apps/business/src/pages/WebsiteSettingsPage.module.css index 7776994..4d651d1 100644 --- a/apps/business/src/pages/WebsiteSettingsPage.module.css +++ b/apps/business/src/pages/WebsiteSettingsPage.module.css @@ -1,64 +1,117 @@ .sourceRow { - align-items: flex-start; + align-items: center; } -.radioGroup { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 200px; +.field { + min-width: 180px; flex-shrink: 0; } -.radioOption { - display: flex; - align-items: center; - gap: 8px; +.field select { + width: 100%; + appearance: none; + -webkit-appearance: none; min-height: var(--field-height); padding: var(--field-padding-y) var(--field-padding-x); + padding-inline-end: var(--select-padding-end); font-size: var(--field-font-size); - line-height: 1.4; + font-family: var(--font-ui); color: var(--text-primary); background-color: rgba(255, 255, 255, 0.7); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--select-arrow-offset) center; + background-size: var(--select-arrow-size); border: 1px solid rgba(148, 163, 184, 0.35); border-radius: var(--radius-sm); cursor: pointer; - transition: border-color 0.2s, box-shadow 0.2s; + outline: none; } -.radioOption:has(input:checked) { - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +:global([dir='rtl']) .field select { + background-position: left var(--select-arrow-offset) center; } -.radioOption:has(input:disabled) { +.field select:disabled { opacity: 0.6; cursor: not-allowed; } -.radioOption input { - width: 16px; - height: 16px; - margin: 0; - accent-color: var(--primary); +.sitemapRow { + align-items: center; +} + +.sitemapActions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; flex-shrink: 0; } +.rebuildBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: var(--field-height); + padding: 0 14px; + font-size: var(--field-font-size); + font-family: var(--font-ui); + font-weight: 600; + color: var(--primary); + background-color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(var(--primary-rgb) / 0.35); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s; +} + +.rebuildBtn:hover:not(:disabled) { + background-color: rgba(var(--primary-rgb) / 0.08); + border-color: var(--primary); +} + +.rebuildBtn:focus-visible { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.rebuildBtn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.sitemapUrl { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-family: var(--font-ui); + color: var(--primary); + text-decoration: none; +} + +.sitemapUrl:hover { + text-decoration: underline; +} + @media (max-width: 768px) { - .radioGroup { + .field, + .sitemapActions { width: 100%; min-width: 0; } -} -.sitemapRow { - align-items: flex-start; -} + .sitemapActions { + align-items: stretch; + } -.sitemapMeta { - margin: 8px 0 0; - font-size: 12px; - color: var(--text-secondary); + .sitemapUrl { + max-width: none; + } } .spin { diff --git a/apps/business/src/pages/WebsiteSettingsPage.tsx b/apps/business/src/pages/WebsiteSettingsPage.tsx index 8a86b5c..006eadf 100644 --- a/apps/business/src/pages/WebsiteSettingsPage.tsx +++ b/apps/business/src/pages/WebsiteSettingsPage.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react' import { RefreshCw } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' import { PageTitle } from '../components/PageTitle' -import { Tooltip } from '../components/Tooltip' import { useToast } from '../context/ToastContext' import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' +import { getBusinessDomain } from '../lib/config' import { getSettings, updateWebsiteSettings, @@ -14,7 +14,6 @@ import { } from '../services/settingsService' import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService' import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules' -import controlStyles from '../components/CategoryRow.module.css' import pageStyles from '../components/PageContent.module.css' import sharedStyles from './ProductSettingsPage.module.css' import styles from './WebsiteSettingsPage.module.css' @@ -32,14 +31,10 @@ function defaultSpecialProductsSource( : 'product' } -function formatSyncedAt(value: string | null | undefined, locale: string) { - if (!value) return null - const date = new Date(value) - if (Number.isNaN(date.getTime())) return null - return date.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-GB', { - dateStyle: 'medium', - timeStyle: 'short', - }) +function resolveSitemapUrl(config: WebsiteSitemapConfig | null): string { + const fromConfig = config?.baseUrl?.trim().replace(/\/+$/, '') + const origin = fromConfig || `https://${getBusinessDomain()}` + return `${origin}/sitemap.xml` } export function WebsiteSettingsPage() { @@ -126,14 +121,7 @@ export function WebsiteSettingsPage() { } } - const syncedAtLabel = formatSyncedAt( - sitemapConfig?.syncedAt, - document.documentElement.lang || 'en', - ) - const staticPageCount = sitemapConfig?.staticPages?.length ?? 0 - const templateCount = sitemapConfig?.templates - ? Object.keys(sitemapConfig.templates).length - : 0 + const sitemapUrl = resolveSitemapUrl(sitemapConfig) return (
@@ -165,35 +153,30 @@ export function WebsiteSettingsPage() {
- + +

{t('website.settings.specialSourceDesc')}

-
- {SOURCE_OPTIONS.map((option) => ( -
@@ -205,35 +188,30 @@ export function WebsiteSettingsPage() {

{t('website.settings.sitemapDesc')}

- {sitemapConfig ? ( -

- {syncedAtLabel - ? t('website.settings.sitemapSyncedAt', { date: syncedAtLabel }) - : t('website.settings.sitemapImported')} - {' · '} - {t('website.settings.sitemapSummary', { - pages: staticPageCount, - templates: templateCount, - })} -

- ) : ( -

{t('website.settings.sitemapNotSynced')}

- )}
- +
- + + {sitemapUrl} + +
)} diff --git a/apps/business/src/services/storeItemService.ts b/apps/business/src/services/storeItemService.ts index fdf3b6f..528f9f3 100644 --- a/apps/business/src/services/storeItemService.ts +++ b/apps/business/src/services/storeItemService.ts @@ -91,9 +91,22 @@ function businessPath(suffix = '') { return `/businesses/${businessId}/store-items${suffix}` } -export async function listStoreItems(page = 1, pageSize = 20, signal?: AbortSignal) { +export async function listStoreItems( + page = 1, + pageSize = 20, + signal?: AbortSignal, + filters?: { name?: string }, +) { + const params = new URLSearchParams({ + page: String(page), + pageSize: String(pageSize), + }) + if (filters?.name?.trim()) { + params.set('name', filters.name.trim()) + } + return apiRequest( - `${businessPath()}?page=${page}&pageSize=${pageSize}`, + `${businessPath()}?${params.toString()}`, { auth: true, signal }, ) } diff --git a/apps/business/src/utils/storeSpecialListings.ts b/apps/business/src/utils/storeSpecialListings.ts index 7cf2495..88708a9 100644 --- a/apps/business/src/utils/storeSpecialListings.ts +++ b/apps/business/src/utils/storeSpecialListings.ts @@ -36,12 +36,64 @@ function specialStoreItemToVariants(item: StoreSpecialStoreItem): StoreItem[] { })) } -export function specialItemsToListings(items: StoreSpecialStoreItem[]): SpecialProductListing[] { - const variants = items.flatMap(specialStoreItemToVariants) - const listings = groupStoreItemsByProduct(variants) +/** Catalog-only specials (product source) may have a store item row with no variants yet. */ +function listingFromStoreItemWithoutVariants( + item: StoreSpecialStoreItem, +): SpecialProductListing { + const representative: StoreItem = { + id: item.id, + storeItemId: item.id, + productId: item.productId, + productTitle: item.productTitle, + productNameFa: item.productNameFa, + productImage: item.productImage, + productTotalStock: 0, + selections: [], + label: item.productTitle, + price: null, + discountedPrice: null, + stockQuantity: null, + rewardPoints: null, + isFestival: false, + sortOrder: item.sortOrder, + createdAt: '', + updatedAt: '', + } - return listings.map((listing) => ({ - ...listing, - storeItemId: listing.representative.storeItemId ?? listing.representative.id, - })) + return { + productId: item.productId, + productTitle: item.productTitle, + productNameFa: item.productNameFa, + productImage: item.productImage, + productTotalStock: 0, + variantCount: 0, + variants: [], + representative, + displayPrice: null, + displayDiscountedPrice: null, + showFestival: false, + storeItemId: item.id, + } +} + +export function specialItemsToListings(items: StoreSpecialStoreItem[]): SpecialProductListing[] { + const listings: SpecialProductListing[] = [] + + for (const item of items) { + const variants = specialStoreItemToVariants(item) + if (variants.length === 0) { + listings.push(listingFromStoreItemWithoutVariants(item)) + continue + } + + const grouped = groupStoreItemsByProduct(variants) + for (const listing of grouped) { + listings.push({ + ...listing, + storeItemId: listing.representative.storeItemId ?? item.id, + }) + } + } + + return listings }