diff --git a/apps/business/src/components/AddVariationModal.tsx b/apps/business/src/components/AddVariationModal.tsx index 176e9aa..1bce1dc 100644 --- a/apps/business/src/components/AddVariationModal.tsx +++ b/apps/business/src/components/AddVariationModal.tsx @@ -3,10 +3,10 @@ import { createPortal } from 'react-dom' import { Plus, Search, X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' -import type { BusinessMessageKey } from '../i18n/messages' -import { COLOR_PRESETS } from '../data/colorPresets' +import { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets' import type { Variation, VariationType } from '../types/variation' -import { SIZE_OPTIONS, normalizeColorNames } from '../types/variation' +import { SIZE_OPTIONS } from '../types/variation' +import { localizeVariationOptionLabel } from '../utils/localizeVariationOption' import { Tooltip } from './Tooltip' import styles from './VariationsModal.module.css' @@ -18,15 +18,42 @@ interface AddVariationModalProps { initial?: Variation | null } +interface SelectedColor { + label: string + hex: string +} + const ANIMATION_MS = 220 +const DEFAULT_CUSTOM_HEX = '#64748B' function seedCustomValues(values: string[]) { const trimmed = values.map((value) => value.trim()).filter(Boolean) return trimmed.length ? trimmed : [''] } -function colorLabelKey(name: string): BusinessMessageKey { - return `categories.variations.color.${name}` as BusinessMessageKey +function normalizeHex(value: string): string { + const trimmed = value.trim() + if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toUpperCase() + if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toUpperCase()}` + return DEFAULT_CUSTOM_HEX +} + +function colorsFromInitial(initial: Variation): SelectedColor[] { + return initial.values + .map((raw, index) => { + const label = raw.trim() + if (!label) return null + const canonical = normalizeColorName(label) + const provided = initial.colorHexes?.[index] + const hex = + (provided && /^#[0-9a-fA-F]{6}$/.test(provided.trim()) + ? provided.trim().toUpperCase() + : null) ?? + getColorHex(canonical) ?? + DEFAULT_CUSTOM_HEX + return { label: canonical, hex } + }) + .filter((item): item is SelectedColor => item !== null) } export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) { @@ -38,10 +65,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A const [closing, setClosing] = useState(false) const [type, setType] = useState('color') const [customName, setCustomName] = useState('') - const [selectedColors, setSelectedColors] = useState([]) + const [selectedColors, setSelectedColors] = useState([]) const [selectedSizes, setSelectedSizes] = useState([]) const [customValues, setCustomValues] = useState(['']) const [colorQuery, setColorQuery] = useState('') + const [customColorName, setCustomColorName] = useState('') + const [customColorHex, setCustomColorHex] = useState(DEFAULT_CUSTOM_HEX) + const [customColorError, setCustomColorError] = useState('') const valueInputRefs = useRef<(HTMLInputElement | null)[]>([]) const pendingFocusIndex = useRef(null) @@ -56,12 +86,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A setMounted(true) setClosing(false) setColorQuery('') + setCustomColorName('') + setCustomColorHex(DEFAULT_CUSTOM_HEX) + setCustomColorError('') if (initial) { setType(initial.type) setCustomName(initial.type === 'custom' ? initial.name : '') - setSelectedColors( - initial.type === 'color' ? normalizeColorNames(initial.values) : [], - ) + setSelectedColors(initial.type === 'color' ? colorsFromInitial(initial) : []) setSelectedSizes(initial.type === 'size' ? [...initial.values] : []) setCustomValues( initial.type === 'custom' ? seedCustomValues(initial.values) : [''], @@ -92,26 +123,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A return () => document.removeEventListener('keydown', onKey) }, [mounted, closing, onClose]) + const selectedColorLabels = useMemo( + () => new Set(selectedColors.map((color) => color.label.toLowerCase())), + [selectedColors], + ) + const filteredColors = useMemo(() => { const q = colorQuery.trim().toLowerCase() if (!q) return COLOR_PRESETS return COLOR_PRESETS.filter((color) => { const en = color.name.toLowerCase() - const fa = t(colorLabelKey(color.name)).toLowerCase() + const fa = localizeVariationOptionLabel(color.name, t).toLowerCase() return en.includes(q) || fa.includes(q) }) }, [colorQuery, t]) if (!mounted) return null - function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) { - setSelected( - selected.includes(value) - ? selected.filter((item) => item !== value) - : [...selected, value], + function togglePresetColor(name: string, hex: string) { + setSelectedColors((prev) => { + const exists = prev.some((item) => item.label === name) + if (exists) return prev.filter((item) => item.label !== name) + return [...prev, { label: name, hex }] + }) + setCustomColorError('') + } + + function removeSelectedColor(label: string) { + setSelectedColors((prev) => prev.filter((item) => item.label !== label)) + } + + function toggleSize(value: string) { + setSelectedSizes((prev) => + prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value], ) } + function addCustomColor() { + const label = customColorName.trim() + if (!label) { + setCustomColorError(t('categories.variations.customColorNameRequired')) + return + } + + const hex = normalizeHex(customColorHex) + const key = label.toLowerCase() + const canonical = normalizeColorName(label) + const alreadySelected = + selectedColorLabels.has(key) || + selectedColorLabels.has(canonical.toLowerCase()) + + if (alreadySelected) { + setCustomColorError(t('categories.variations.customColorExists')) + return + } + + setSelectedColors((prev) => [...prev, { label, hex }]) + setCustomColorName('') + setCustomColorHex(DEFAULT_CUSTOM_HEX) + setCustomColorError('') + } + function updateCustomValue(index: number, value: string) { setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v))) } @@ -142,7 +214,12 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A e.preventDefault() if (type === 'color') { - onSubmit({ name: t('categories.variations.type.color'), type: 'color', values: selectedColors }) + onSubmit({ + name: t('categories.variations.type.color'), + type: 'color', + values: selectedColors.map((color) => color.label), + colorHexes: selectedColors.map((color) => color.hex), + }) } else if (type === 'size') { onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes }) } else { @@ -224,27 +301,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A {selectedColors.length > 0 ? (
- {selectedColors.map((color) => { - const preset = COLOR_PRESETS.find((item) => item.name === color) - return ( - - ) - })} + {selectedColors.map((color) => ( + + ))}
) : null}
@@ -256,22 +328,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A key={color.name} type="button" className={`${styles.chip} ${ - selectedColors.includes(color.name) ? styles.chipSelected : '' + selectedColorLabels.has(color.name.toLowerCase()) + ? styles.chipSelected + : '' }`} - onClick={() => - toggleOption(color.name, selectedColors, setSelectedColors) - } + onClick={() => togglePresetColor(color.name, color.hex)} >
+ +
+ + {t('categories.variations.addCustomColor')} + +
+ { + setCustomColorName(e.target.value) + if (customColorError) setCustomColorError('') + }} + placeholder={t('categories.variations.customColorNamePlaceholder')} + aria-label={t('categories.variations.customColorName')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addCustomColor() + } + }} + /> + + +
+ {customColorError ? ( +

{customColorError}

+ ) : null} +
)} @@ -284,7 +401,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A key={size} type="button" className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`} - onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)} + onClick={() => toggleSize(size)} > {size} diff --git a/apps/business/src/components/CreateProductVariantModal.tsx b/apps/business/src/components/CreateProductVariantModal.tsx index f23fe83..1e23d3a 100644 --- a/apps/business/src/components/CreateProductVariantModal.tsx +++ b/apps/business/src/components/CreateProductVariantModal.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' +import { useT } from '../i18n/useT' import type { ProductVariant } from '../types/productVariant' import { buildVariantLabel } from '../types/productVariant' import type { ProductVariantAttribute } from '../services/productVariantService' +import { localizeVariationOptionLabel } from '../utils/localizeVariationOption' import styles from './VariationsModal.module.css' interface CreateProductVariantModalProps { @@ -27,6 +29,7 @@ export function CreateProductVariantModal({ onClose, onSubmit, }: CreateProductVariantModalProps) { + const t = useT() const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [variationId, setVariationId] = useState('') @@ -150,7 +153,7 @@ export function CreateProductVariantModal({ > {options.map((option) => ( ))} diff --git a/apps/business/src/components/CreateStoreItemsModal.tsx b/apps/business/src/components/CreateStoreItemsModal.tsx index 5223034..44da273 100644 --- a/apps/business/src/components/CreateStoreItemsModal.tsx +++ b/apps/business/src/components/CreateStoreItemsModal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { PackagePlus, Plus, X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useToast } from '../context/ToastContext' import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' -import { listAllProducts } from '../services/productService' +import { listAllProducts, listProducts } from '../services/productService' import { getProductVariationValues, type ProductVariationSelection, @@ -22,11 +22,15 @@ import { getVariationOptions, type StoreItemDraftRow, } from '../utils/storeItemRows' +import { localizeVariationOptionLabel } from '../utils/localizeVariationOption' import { ConfirmBulkAddStoreItemsModal, type BulkAddResult, } from './ConfirmBulkAddStoreItemsModal' -import { ProductSearchSelect } from './ProductSearchSelect' +import { + ProductSearchSelect, + type ProductSearchOption, +} from './ProductSearchSelect' import { Tooltip } from './Tooltip' import modalStyles from './VariationsModal.module.css' import rowStyles from './CreateStoreItemsModal.module.css' @@ -40,6 +44,7 @@ interface CreateStoreItemsModalProps { } const ANIMATION_MS = 220 +const PRODUCT_SEARCH_PAGE_SIZE = 20 export function CreateStoreItemsModal({ open, @@ -53,21 +58,29 @@ export function CreateStoreItemsModal({ const { showToast } = useToast() const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) - const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([]) + const [searchResults, setSearchResults] = useState([]) + const [selectedProduct, setSelectedProduct] = useState(null) const [productId, setProductId] = useState('') const [isUsed, setIsUsed] = useState(false) const [variations, setVariations] = useState([]) const [rows, setRows] = useState([]) - const [isLoadingProducts, setIsLoadingProducts] = useState(false) + const [isSearchingProducts, setIsSearchingProducts] = useState(false) const [isLoadingVariations, setIsLoadingVariations] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [isBulkCreating, setIsBulkCreating] = useState(false) const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) const [bulkResult, setBulkResult] = useState(null) const [error, setError] = useState('') + const searchControllerRef = useRef(null) const busy = isSubmitting || isBulkCreating + const productOptions = useMemo(() => { + if (!selectedProduct) return searchResults + if (searchResults.some((item) => item.id === selectedProduct.id)) return searchResults + return [selectedProduct, ...searchResults] + }, [searchResults, selectedProduct]) + const formVariations = useMemo( () => variations.filter((variation) => getVariationOptions(variation).length > 0), [variations], @@ -120,6 +133,8 @@ export function CreateStoreItemsModal({ setClosing(false) setProductId('') setIsUsed(false) + setSelectedProduct(null) + setSearchResults([]) setVariations([]) setRows([]) setError('') @@ -131,13 +146,54 @@ export function CreateStoreItemsModal({ }, [open, mounted]) useEffect(() => { - if (!open) return - - const controller = new AbortController() - void loadProducts(controller.signal) - return () => controller.abort() + if (!open) { + searchControllerRef.current?.abort() + return + } }, [open]) + const searchProducts = useCallback( + async (query: string) => { + searchControllerRef.current?.abort() + + if (!query) { + setSearchResults([]) + setIsSearchingProducts(false) + return + } + + const controller = new AbortController() + searchControllerRef.current = controller + setIsSearchingProducts(true) + + try { + const data = await listProducts(1, PRODUCT_SEARCH_PAGE_SIZE, controller.signal, { + name: query, + }) + if (controller.signal.aborted) return + setSearchResults( + data.items.map((item) => ({ + id: item.id, + title: item.title, + nameFa: item.nameFa, + })), + ) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (controller.signal.aborted) return + setSearchResults([]) + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('storeItems.create.errorProducts')) + } + } finally { + if (!controller.signal.aborted) setIsSearchingProducts(false) + } + }, + [t], + ) + useEffect(() => { if (!productId) { setVariations([]) @@ -150,31 +206,6 @@ export function CreateStoreItemsModal({ return () => controller.abort() }, [productId]) - async function loadProducts(signal?: AbortSignal) { - setIsLoadingProducts(true) - setError('') - - try { - const items = await listAllProducts(signal) - setProducts( - items.map((item) => ({ - id: item.id, - title: item.title, - nameFa: item.nameFa, - })), - ) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - if (err instanceof ApiError) { - setError(err.message) - } else { - setError(t('storeItems.create.errorProducts')) - } - } finally { - setIsLoadingProducts(false) - } - } - async function loadVariations(nextProductId: string, signal?: AbortSignal) { setIsLoadingVariations(true) setError('') @@ -215,6 +246,15 @@ export function CreateStoreItemsModal({ return } setProductId(nextProductId) + if (!nextProductId) { + setSelectedProduct(null) + return + } + const match = + productOptions.find((item) => item.id === nextProductId) ?? + searchResults.find((item) => item.id === nextProductId) ?? + null + setSelectedProduct(match) } function updateRow(rowId: string, patch: Partial) { @@ -300,7 +340,7 @@ export function CreateStoreItemsModal({ } function openBulkConfirm() { - if (busy || isLoadingProducts || products.length === 0) return + if (busy) return setBulkResult(null) setBulkConfirmOpen(true) } @@ -318,7 +358,7 @@ export function CreateStoreItemsModal({ } async function handleAddAllProducts() { - if (isBulkCreating || isLoadingProducts) return + if (isBulkCreating) return setIsBulkCreating(true) setError('') @@ -330,14 +370,11 @@ export function CreateStoreItemsModal({ const skip = new Set(existingProductIds) try { - const catalog = - products.length > 0 - ? products - : (await listAllProducts()).map((item) => ({ - id: item.id, - title: item.title, - nameFa: item.nameFa, - })) + const catalog = (await listAllProducts()).map((item) => ({ + id: item.id, + title: item.title, + nameFa: item.nameFa, + })) for (const product of catalog) { if (skip.has(product.id)) { @@ -415,11 +452,13 @@ export function CreateStoreItemsModal({
void searchProducts(query)} + isSearching={isSearchingProducts} placeholder={t('storeItems.create.productPlaceholder')} - disabled={isLoadingProducts || busy} + disabled={busy} aria-label={t('storeItems.create.productPlaceholder')} />
@@ -476,7 +515,7 @@ export function CreateStoreItemsModal({ {options.map((option) => ( ))} @@ -551,7 +590,7 @@ export function CreateStoreItemsModal({ type="button" className={rowStyles.footerBulkBtn} onClick={openBulkConfirm} - disabled={busy || isLoadingProducts || products.length === 0} + disabled={busy} > {t('storeItems.create.addAllProducts')} diff --git a/apps/business/src/components/EditStoreItemsModal.tsx b/apps/business/src/components/EditStoreItemsModal.tsx index 672d2b3..d9823b2 100644 --- a/apps/business/src/components/EditStoreItemsModal.tsx +++ b/apps/business/src/components/EditStoreItemsModal.tsx @@ -19,6 +19,7 @@ import { getVariationOptions, type StoreItemDraftRow, } from '../utils/storeItemRows' +import { localizeVariationOptionLabel } from '../utils/localizeVariationOption' import { Tooltip } from './Tooltip' import modalStyles from './VariationsModal.module.css' import rowStyles from './CreateStoreItemsModal.module.css' @@ -343,7 +344,7 @@ export function EditStoreItemsModal({ {options.map((option) => ( ))} diff --git a/apps/business/src/components/MultiSelectDropdown.module.css b/apps/business/src/components/MultiSelectDropdown.module.css index 8250137..fb56fe8 100644 --- a/apps/business/src/components/MultiSelectDropdown.module.css +++ b/apps/business/src/components/MultiSelectDropdown.module.css @@ -13,7 +13,7 @@ font-size: var(--field-font-size); font-family: inherit; color: var(--text-primary); - text-align: left; + text-align: start; background: var(--surface); border: 1px solid rgba(148, 163, 184, 0.35); border-radius: var(--radius-sm); diff --git a/apps/business/src/components/ProductCard.module.css b/apps/business/src/components/ProductCard.module.css index 7601b3a..8a7b44b 100644 --- a/apps/business/src/components/ProductCard.module.css +++ b/apps/business/src/components/ProductCard.module.css @@ -155,6 +155,10 @@ border-radius: 50px; direction: rtl; unicode-bidi: plaintext; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; } .bodyLtr { diff --git a/apps/business/src/components/ProductCard.tsx b/apps/business/src/components/ProductCard.tsx index 1358cc5..c6785dd 100644 --- a/apps/business/src/components/ProductCard.tsx +++ b/apps/business/src/components/ProductCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from 'react' +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Pencil, @@ -7,7 +7,6 @@ import { Layers, ClipboardList, ImageOff, - ExternalLink, Info, MessageSquare, Search, @@ -20,8 +19,6 @@ import { Tooltip } from './Tooltip' import { useTenantBranding } from '../context/TenantBrandingContext' import { hasBusinessModule } from '../utils/businessModules' import { formatProductCategoryChip } from '../services/productService' -import { buildWebsiteContentUrl } from '../utils/websiteLinks' -import { OpenOnWebsiteControl } from './OpenOnWebsiteControl' import { ProductCardOverflowMenu } from './ProductCardOverflowMenu' import styles from './ProductCard.module.css' @@ -46,7 +43,6 @@ type CardAction = { onClick: () => void danger?: boolean badge?: number - render?: () => ReactNode } export function ProductCard({ @@ -75,7 +71,6 @@ export function ProductCard({ : '' : product.nameFa const categoryLabel = formatProductCategoryChip(product, isFa) - const websiteUrl = buildWebsiteContentUrl('product', product) const imageSrc = product.image?.trim() || '' const [imageFailed, setImageFailed] = useState(false) const showImage = Boolean(imageSrc) && !imageFailed @@ -91,15 +86,6 @@ export function ProductCard({ icon: Pencil, onClick: () => onEdit(product.id), }, - { - key: 'website', - label: t('common.viewOnWebsite'), - icon: ExternalLink, - onClick: () => { - if (websiteUrl) window.open(websiteUrl, '_blank', 'noopener,noreferrer') - }, - render: () => , - }, ...(showTranslationControls ? [ { @@ -192,15 +178,15 @@ export function ProductCard({ )} {categoryLabel ? ( - {categoryLabel} + + {categoryLabel} + ) : null}
{primaryActions.map((action) => { - if (action.render) return {action.render()} - const Icon = action.icon return ( diff --git a/apps/business/src/components/ProductSearchSelect.tsx b/apps/business/src/components/ProductSearchSelect.tsx index 99c2802..872658f 100644 --- a/apps/business/src/components/ProductSearchSelect.tsx +++ b/apps/business/src/components/ProductSearchSelect.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, Search, X } from 'lucide-react' +import { ChevronDown, Loader2, Search, X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import styles from './SearchableSelect.module.css' +export const PRODUCT_SEARCH_DEBOUNCE_MS = 350 + export interface ProductSearchOption { id: string title: string @@ -18,6 +20,13 @@ interface ProductSearchSelectProps { disabled?: boolean id?: string minSearchLength?: number + isSearching?: boolean + /** + * When set, typing is debounced and the parent fetches results. + * Local filtering is skipped. + */ + onQueryChange?: (query: string) => void + debounceMs?: number 'aria-label'?: string } @@ -36,6 +45,9 @@ export function ProductSearchSelect({ disabled = false, id, minSearchLength = 3, + isSearching = false, + onQueryChange, + debounceMs = PRODUCT_SEARCH_DEBOUNCE_MS, 'aria-label': ariaLabel, }: ProductSearchSelectProps) { const t = useT() @@ -45,20 +57,35 @@ export function ProductSearchSelect({ const [query, setQuery] = useState('') const containerRef = useRef(null) const inputRef = useRef(null) + const onQueryChangeRef = useRef(onQueryChange) + onQueryChangeRef.current = onQueryChange const selected = options.find((option) => option.id === value) const selectedLabel = selected ? formatProductLabel(selected, preferFa) : '' + const trimmedQuery = query.trim() + const canSearch = trimmedQuery.length >= minSearchLength + + useEffect(() => { + const cb = onQueryChangeRef.current + if (!cb) return + if (!canSearch) { + cb('') + return + } + const timer = window.setTimeout(() => cb(trimmedQuery), debounceMs) + return () => window.clearTimeout(timer) + }, [canSearch, trimmedQuery, debounceMs]) const filtered = useMemo(() => { - const trimmed = query.trim() - if (trimmed.length < minSearchLength) return [] - const q = trimmed.toLowerCase() + if (!canSearch) return [] + if (onQueryChange) return options + const q = trimmedQuery.toLowerCase() return options.filter( (option) => option.title.toLowerCase().includes(q) || - option.nameFa.includes(trimmed), + option.nameFa.includes(trimmedQuery), ) - }, [options, query, minSearchLength]) + }, [canSearch, onQueryChange, options, trimmedQuery]) useEffect(() => { if (!open) return @@ -76,13 +103,14 @@ export function ProductSearchSelect({ onChange(id) setOpen(false) setQuery('') + onQueryChangeRef.current?.('') } function handleFocus() { if (!disabled) setOpen(true) } - const showDropdown = open && query.trim().length >= minSearchLength + const showDropdown = open && (canSearch || trimmedQuery.length > 0) return (
@@ -115,17 +143,19 @@ export function ProductSearchSelect({ )} - + {isSearching && open ? ( + + ) : ( + + )}
- {showDropdown && ( + {showDropdown && canSearch && (
    - {filtered.length === 0 ? ( -
  • - {options.length === 0 - ? t('storeItems.search.noProducts') - : t('storeItems.search.noMatch')} -
  • + {isSearching && filtered.length === 0 ? ( +
  • {t('storeItems.search.searching')}
  • + ) : filtered.length === 0 ? ( +
  • {t('storeItems.search.noMatch')}
  • ) : ( filtered.map((option) => (
  • @@ -172,7 +202,7 @@ export function ProductSearchSelect({
)} - {open && query.trim().length > 0 && query.trim().length < minSearchLength && ( + {open && trimmedQuery.length > 0 && trimmedQuery.length < minSearchLength && (
  • {t('storeItems.search.minChars', { count: minSearchLength })} diff --git a/apps/business/src/components/ProductVariantsModal.tsx b/apps/business/src/components/ProductVariantsModal.tsx index e032acb..1a4112d 100644 --- a/apps/business/src/components/ProductVariantsModal.tsx +++ b/apps/business/src/components/ProductVariantsModal.tsx @@ -1,6 +1,8 @@ import { useEffect, 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 { countSelectedOptions, @@ -8,6 +10,7 @@ import { saveProductVariationValues, type ProductVariationSelection, } from '../services/productVariationService' +import { localizeVariationOptionLabel } from '../utils/localizeVariationOption' import { MultiSelectDropdown } from './MultiSelectDropdown' import styles from './VariationsModal.module.css' @@ -30,6 +33,9 @@ export function ProductVariantsModal({ onClose, onVariantsChange, }: ProductVariantsModalProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [variations, setVariations] = useState([]) @@ -72,7 +78,7 @@ export function ProductVariantsModal({ if (err instanceof ApiError) { setError(err.message) } else { - setError('Unable to load product variations.') + setError(t('products.variants.loadError')) } } finally { setIsLoading(false) @@ -90,6 +96,12 @@ export function ProductVariantsModal({ if (!mounted) return null + function variationLabel(variation: ProductVariationSelection) { + if (variation.type === 'color') return t('categories.variations.type.color') + if (variation.type === 'size') return t('categories.variations.type.size') + return variation.name + } + function handleSelectionChange(variationId: string, optionIds: string[]) { setVariations((prev) => prev.map((variation) => @@ -113,7 +125,7 @@ export function ProductVariantsModal({ if (err instanceof ApiError) { setError(err.message) } else { - setError('Unable to save product variations.') + setError(t('products.variants.saveError')) } } finally { setIsSaving(false) @@ -131,54 +143,63 @@ export function ProductVariantsModal({ role="dialog" aria-modal="true" aria-labelledby="product-variants-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} >

    - Product Variations + {t('products.variants.title')}

    {productName &&

    {productName}

    }
    -
    {!categoryId && ( -

    - Assign a category to this product before managing variations. -

    +

    {t('products.variants.needCategory')}

    )} {error &&

    {error}

    } {isLoading ? ( -

    Loading variations...

    +

    {t('products.variants.loading')}

    ) : variations.length === 0 ? (

    {categoryId - ? 'This category has no variations. Add variations on the category first.' - : 'No variations available.'} + ? t('products.variants.emptyCategory') + : t('products.variants.empty')}

    ) : (
    - {variations.map((variation) => ( -
    - - ({ - value: option.id, - label: option.label, - }))} - value={variation.selectedOptionIds} - onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)} - placeholder={`Select ${variation.name.toLowerCase()} values`} - disabled={isSaving} - /> -
    - ))} + {variations.map((variation) => { + const name = variationLabel(variation) + return ( +
    + + ({ + value: option.id, + label: localizeVariationOptionLabel(option.label, t), + }))} + value={variation.selectedOptionIds} + onChange={(optionIds) => + handleSelectionChange(variation.id, optionIds) + } + placeholder={t('products.variants.selectValues', { name })} + disabled={isSaving} + /> +
    + ) + })}
    )} @@ -189,7 +210,7 @@ export function ProductVariantsModal({ onClick={onClose} disabled={isSaving} > - Cancel + {t('common.cancel')}
- - , - document.body, + , + document.body, ) } diff --git a/apps/business/src/components/SearchableSelect.module.css b/apps/business/src/components/SearchableSelect.module.css index 2461b4b..6d48491 100644 --- a/apps/business/src/components/SearchableSelect.module.css +++ b/apps/business/src/components/SearchableSelect.module.css @@ -101,6 +101,16 @@ z-index: auto; } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.spin { + animation: spin 0.8s linear infinite; +} + @keyframes dropdownIn { from { opacity: 0; diff --git a/apps/business/src/components/Sidebar.module.css b/apps/business/src/components/Sidebar.module.css index fc584e8..57f98bc 100644 --- a/apps/business/src/components/Sidebar.module.css +++ b/apps/business/src/components/Sidebar.module.css @@ -25,22 +25,24 @@ .brandLogo { display: block; - width: 40px; - height: 40px; + /* Same height as brandName + domain stack; width follows image aspect ratio */ + height: calc(13px * 1.2 + 2px + 11px * 1.2); + width: auto; + max-width: 7.5rem; object-fit: contain; flex-shrink: 0; background: transparent; } .brandLogoUploaded { - border-radius: 8px; - object-fit: cover; - background: var(--card-media-bg); + border-radius: 0; + object-fit: contain; + background: transparent; } .brandEditBtn { - width: 40px; - height: 40px; + width: calc(13px * 1.2 + 2px + 11px * 1.2); + height: calc(13px * 1.2 + 2px + 11px * 1.2); display: flex; align-items: center; justify-content: center; @@ -69,6 +71,7 @@ font-weight: 500; color: var(--text-muted); margin-top: 2px; + line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/apps/business/src/components/StoreItemCard.module.css b/apps/business/src/components/StoreItemCard.module.css index f7bfade..ed22d32 100644 --- a/apps/business/src/components/StoreItemCard.module.css +++ b/apps/business/src/components/StoreItemCard.module.css @@ -200,7 +200,8 @@ justify-content: space-around; } -.controlsLeft button { +.controlsLeft button, +.controlsLeft a { width: 32px; height: 32px; display: flex; @@ -208,10 +209,12 @@ justify-content: center; border-radius: 8px; color: var(--text-secondary); + text-decoration: none; transition: background 0.2s, color 0.2s; } -.controlsLeft button:hover { +.controlsLeft button:hover, +.controlsLeft a:hover { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); } diff --git a/apps/business/src/components/StoreItemCard.tsx b/apps/business/src/components/StoreItemCard.tsx index ad44fff..71ad74e 100644 --- a/apps/business/src/components/StoreItemCard.tsx +++ b/apps/business/src/components/StoreItemCard.tsx @@ -6,6 +6,8 @@ import { formatVariantCount, type StoreProductListing, } from '../utils/storeProductGroups' +import { buildWebsiteContentUrl } from '../utils/websiteLinks' +import { OpenOnWebsiteControl } from './OpenOnWebsiteControl' import { StoreItemPrice } from './StoreItemPrice' import { Tooltip } from './Tooltip' import styles from './StoreItemCard.module.css' @@ -83,6 +85,14 @@ export function StoreItemCard({ const imageSrc = listing.productImage?.trim() || '' const [imageFailed, setImageFailed] = useState(false) const showImage = Boolean(imageSrc) && !imageFailed + const websiteUrl = + listing.variantCount > 0 + ? buildWebsiteContentUrl('product', { + id: listing.productId, + nameFa: listing.productNameFa, + nameEn: listing.productTitle, + }) + : '' useEffect(() => { setImageFailed(false) @@ -167,6 +177,7 @@ export function StoreItemCard({ + + + + +