From e0e68de30ff25e2a667cacdd600e009b152e2570 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 17 Aug 2026 00:30:01 +0330 Subject: [PATCH] Add a reusable website link generator and polish static image cards. Editors can pick product, store, blog, portfolio, or video category links, and image cards expose compact info/link actions without dumping URLs in the footer. Co-authored-by: Cursor --- .../AddWebsiteSliderSlideModal.module.css | 35 ++- .../components/AddWebsiteSliderSlideModal.tsx | 50 +-- .../src/components/CategoryModal.module.css | 35 ++- .../components/CategoryTreeSelect.module.css | 191 ++++++++++++ .../src/components/CategoryTreeSelect.tsx | 291 ++++++++++++++++++ .../GenerateWebsiteLinkModal.module.css | 91 ++++++ .../components/GenerateWebsiteLinkModal.tsx | 273 ++++++++++++++++ .../src/components/SearchableSelect.tsx | 22 +- .../src/components/Tooltip.module.css | 8 +- apps/business/src/components/Tooltip.tsx | 4 +- .../WebsiteStaticImageGallery.module.css | 56 +++- .../components/WebsiteStaticImageGallery.tsx | 95 ++++-- apps/business/src/i18n/messages.ts | 40 +++ apps/business/src/utils/categories.ts | 13 + apps/business/src/utils/websiteLinks.ts | 37 +++ 15 files changed, 1164 insertions(+), 77 deletions(-) create mode 100644 apps/business/src/components/CategoryTreeSelect.module.css create mode 100644 apps/business/src/components/CategoryTreeSelect.tsx create mode 100644 apps/business/src/components/GenerateWebsiteLinkModal.module.css create mode 100644 apps/business/src/components/GenerateWebsiteLinkModal.tsx create mode 100644 apps/business/src/utils/websiteLinks.ts diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.module.css b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css index 1d3ca26..39b2910 100644 --- a/apps/business/src/components/AddWebsiteSliderSlideModal.module.css +++ b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css @@ -11,20 +11,35 @@ } .formFit { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.fields { flex: 1 1 auto; min-height: 0; overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; } .actionsBar { - position: sticky; - bottom: 0; - z-index: 1; - margin-top: auto; - padding-top: 12px; - background: linear-gradient( - to top, - rgba(255, 255, 255, 0.96) 72%, - rgba(255, 255, 255, 0.78) - ); + flex-shrink: 0; + margin-top: 0; + padding-top: 14px; +} + +.linkRow { + display: flex; + gap: 8px; + align-items: stretch; +} + +.linkRow input { + flex: 1; + min-width: 0; } diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.tsx b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx index 3a6f93d..cbf71b7 100644 --- a/apps/business/src/components/AddWebsiteSliderSlideModal.tsx +++ b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' import { ImageCropper } from './ImageCropper' +import { GenerateWebsiteLinkButton } from './GenerateWebsiteLinkModal' import { useT } from '../i18n/useT' import modalStyles from './CategoryModal.module.css' import styles from './AddWebsiteSliderSlideModal.module.css' @@ -128,17 +129,18 @@ export function AddWebsiteSliderSlideModal({ className={`${modalStyles.form} ${styles.formFit}`} onSubmit={handleSubmit} > -
- - -
+
+
+ + +
{captionMode === 'bilingual' ? ( <> @@ -197,15 +199,20 @@ export function AddWebsiteSliderSlideModal({
- setLinkUrl(e.target.value)} - disabled={isSubmitting} - /> +
+ setLinkUrl(e.target.value)} + disabled={isSubmitting} + /> + +
+
@@ -227,8 +234,7 @@ export function AddWebsiteSliderSlideModal({
- - , + , document.body, ) } diff --git a/apps/business/src/components/CategoryModal.module.css b/apps/business/src/components/CategoryModal.module.css index 6fc0b77..fcca78c 100644 --- a/apps/business/src/components/CategoryModal.module.css +++ b/apps/business/src/components/CategoryModal.module.css @@ -16,6 +16,32 @@ z-index: 1200; } +.overlayIn { + animation: overlayFadeIn 0.2s ease forwards; +} + +.overlayOut { + animation: overlayFadeOut 0.18s ease forwards; +} + +@keyframes overlayFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes overlayFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + .modal { width: 100%; max-width: 480px; @@ -103,12 +129,19 @@ .field select { appearance: none; -webkit-appearance: none; - padding-right: var(--select-padding-end); + min-height: var(--field-height); + padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x); + font-size: var(--field-font-size); + color: var(--text-primary); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); 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); + outline: none; + box-sizing: border-box; cursor: pointer; } diff --git a/apps/business/src/components/CategoryTreeSelect.module.css b/apps/business/src/components/CategoryTreeSelect.module.css new file mode 100644 index 0000000..085cbf4 --- /dev/null +++ b/apps/business/src/components/CategoryTreeSelect.module.css @@ -0,0 +1,191 @@ +.wrapper { + display: flex; + flex-direction: column; + min-height: 0; +} + +.inputWrap { + display: flex; + align-items: center; + gap: 6px; + min-height: var(--field-height); + padding: 0 var(--field-padding-x); + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.inputWrapOpen, +.inputWrap:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.searchIcon { + flex-shrink: 0; + color: var(--text-muted); +} + +.input { + flex: 1; + min-width: 0; + padding: var(--field-padding-y) 0; + font-size: var(--field-font-size); + font-family: var(--font-ui); + line-height: 1.4; + color: var(--text-primary); + background: transparent; + border: none; + outline: none; +} + +.input::placeholder { + font-family: var(--font-ui); + color: var(--text-muted); + opacity: 1; +} + +.clearBtn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + color: var(--text-muted); + transition: background 0.2s, color 0.2s; +} + +.clearBtn:hover { + background: rgba(148, 163, 184, 0.2); + color: var(--text-secondary); +} + +.fieldChevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.fieldChevronOpen { + transform: rotate(180deg); +} + +.panel { + max-height: min(42vh, 320px); + overflow-y: auto; + margin-top: 6px; + padding: 4px; + background: rgba(255, 255, 255, 0.4); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); +} + +.row { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 6px 8px; + border-radius: 8px; + text-align: start; + color: var(--text-primary); + transition: background 0.15s; +} + +.row:hover { + background: rgba(var(--primary-rgb) / 0.08); +} + +.rowSelected { + background: rgba(var(--primary-rgb) / 0.12); + color: var(--primary); +} + +.rowSelected:hover { + background: rgba(var(--primary-rgb) / 0.16); +} + +.toggleBtn { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--text-muted); + background: transparent; + cursor: pointer; + outline: none; + transition: background 0.15s, color 0.15s, transform 0.2s; +} + +.toggleBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.toggleSpacer { + flex-shrink: 0; + width: 24px; + height: 24px; +} + +.chevron { + display: inline-flex; + transition: transform 0.2s ease; +} + +:global([dir='rtl']) .chevron { + transform: scaleX(-1); +} + +.chevronOpen { + transform: rotate(90deg); +} + +:global([dir='rtl']) .chevronOpen { + transform: scaleX(-1) rotate(90deg); +} + +.selectBtn { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0; + text-align: start; + color: inherit; + font-size: var(--field-font-size); + font-family: var(--font-ui); + background: transparent; + cursor: pointer; + outline: none; +} + +.name { + font-weight: 500; +} + +.nameSecondary { + font-size: 12px; + font-weight: 400; + color: var(--text-muted); +} + +.rowSelected .nameSecondary { + color: inherit; + opacity: 0.75; +} + +.noResults { + padding: 16px 12px; + font-size: 13px; + color: var(--text-muted); + text-align: center; +} diff --git a/apps/business/src/components/CategoryTreeSelect.tsx b/apps/business/src/components/CategoryTreeSelect.tsx new file mode 100644 index 0000000..3043779 --- /dev/null +++ b/apps/business/src/components/CategoryTreeSelect.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown, ChevronRight, Search, X } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import { useT } from '../i18n/useT' +import type { Category } from '../types/category' +import { getAncestorIds, getChildren } from '../utils/categories' +import { Tooltip } from './Tooltip' +import treeStyles from './CategoryTree.module.css' +import styles from './CategoryTreeSelect.module.css' + +interface CategoryTreeSelectProps { + categories: Category[] + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + id?: string +} + +function categoryLabel(category: Category, isFa: boolean) { + if (isFa) { + return category.nameFa + ? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}` + : category.nameEn + } + return category.nameEn + ? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}` + : category.nameFa +} + +function categoryMatches(category: Category, query: string) { + const q = query.trim().toLowerCase() + if (!q) return true + return ( + category.nameEn.toLowerCase().includes(q) || + category.nameFa.toLowerCase().includes(q) || + category.nameFa.includes(query.trim()) + ) +} + +function visibleIdsForQuery(categories: Category[], query: string): Set | null { + const q = query.trim() + if (!q) return null + + const matched = categories.filter((category) => categoryMatches(category, q)) + const visible = new Set() + + for (const category of matched) { + visible.add(category.id) + for (const ancestorId of getAncestorIds(categories, category.id)) { + visible.add(ancestorId) + } + } + + return visible +} + +function TreeNodes({ + categories, + parentId, + value, + expandedIds, + visibleIds, + forceExpanded, + onToggle, + onSelect, +}: { + categories: Category[] + parentId: string | null + value: string + expandedIds: Set + visibleIds: Set | null + forceExpanded: boolean + onToggle: (id: string) => void + onSelect: (id: string) => void +}) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const nodes = getChildren(categories, parentId).filter( + (category) => !visibleIds || visibleIds.has(category.id), + ) + + return ( + <> + {nodes.map((category) => { + const childCount = getChildren(categories, category.id).some( + (child) => !visibleIds || visibleIds.has(child.id), + ) + const expanded = forceExpanded || expandedIds.has(category.id) + const selected = value === category.id + const primary = isFa ? category.nameFa || category.nameEn : category.nameEn + const secondary = isFa + ? category.nameFa && category.nameEn && category.nameFa !== category.nameEn + ? category.nameEn + : '' + : category.nameFa + + return ( +
+
+ {childCount ? ( + + + + ) : ( + + )} + +
+ {childCount ? ( +
+
+ +
+
+ ) : null} +
+ ) + })} + + ) +} + +export function CategoryTreeSelect({ + categories, + value, + onChange, + placeholder, + disabled = false, + id, +}: CategoryTreeSelectProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [expandedIds, setExpandedIds] = useState>(() => new Set()) + const containerRef = useRef(null) + + const selected = categories.find((category) => category.id === value) + const selectedLabel = selected ? categoryLabel(selected, isFa) : '' + const searchPlaceholder = placeholder ?? t('categories.select.search') + + const visibleIds = useMemo( + () => visibleIdsForQuery(categories, query), + [categories, query], + ) + + useEffect(() => { + setOpen(false) + setQuery('') + setExpandedIds(new Set()) + }, [categories]) + + useEffect(() => { + if (!open) return + function onClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + setQuery('') + } + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, [open]) + + useEffect(() => { + if (!value) return + setExpandedIds((prev) => { + const next = new Set(prev) + for (const ancestorId of getAncestorIds(categories, value)) { + next.add(ancestorId) + } + return next + }) + }, [categories, value]) + + function handleToggle(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function selectOption(id: string) { + onChange(id) + setOpen(false) + setQuery('') + } + + const searching = query.trim().length > 0 + const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0 + + return ( +
+
+ + { + setQuery(e.target.value) + if (!open) setOpen(true) + }} + onClick={() => { + if (!disabled) setOpen(true) + }} + autoComplete="off" + role="combobox" + aria-expanded={open} + aria-autocomplete="list" + /> + {value && !open && !disabled ? ( + + ) : null} + +
+ + {open && !disabled ? ( +
+ {!hasVisible ? ( +

{t('categories.select.empty')}

+ ) : ( + + )} +
+ ) : null} +
+ ) +} diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.module.css b/apps/business/src/components/GenerateWebsiteLinkModal.module.css new file mode 100644 index 0000000..0e4cf21 --- /dev/null +++ b/apps/business/src/components/GenerateWebsiteLinkModal.module.css @@ -0,0 +1,91 @@ +.modal { + max-width: 480px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + flex-shrink: 0; + 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; +} + +.form { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.treeField { + min-height: 0; +} + +.actions { + flex-shrink: 0; + margin-top: auto; + padding-top: 10px; +} + +.preview { + margin: 0; + padding: 8px 10px; + font-size: 12px; + font-family: var(--font-ui); + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.45); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-sm); + word-break: break-all; +} + +.error { + margin: 0; + font-size: 12px; + color: #ef4444; +} + +.status { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + +.generateBtn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: var(--field-height); + padding: 0 12px; + font-size: var(--field-font-size); + font-weight: 500; + color: var(--primary); + background: rgba(var(--primary-rgb) / 0.08); + border-radius: var(--radius-sm); + white-space: nowrap; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.generateBtn:hover:not(:disabled) { + background: rgba(var(--primary-rgb) / 0.14); +} + +.generateBtn:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx new file mode 100644 index 0000000..cb1cbd8 --- /dev/null +++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Link2, X } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' +import { useT } from '../i18n/useT' +import type { BusinessMessageKey } from '../i18n/messages' +import { ApiError } from '../lib/api' +import { listBlogCategories } from '../services/blogCategoryService' +import { listPortfolioCategories } from '../services/portfolioCategoryService' +import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' +import { listVideoCategories } from '../services/videoCategoryService' +import type { ProductCategory } from '../types/category' +import { + WEBSITE_LINK_KINDS, + buildWebsiteCategoryUrl, + type WebsiteLinkKind, +} from '../utils/websiteLinks' +import { CategoryTreeSelect } from './CategoryTreeSelect' +import modalStyles from './CategoryModal.module.css' +import styles from './GenerateWebsiteLinkModal.module.css' + +export type { WebsiteLinkKind } +export { buildWebsiteCategoryUrl } + +const KIND_LABEL: Record = { + product: 'website.linkGenerator.types.product', + storeItem: 'website.linkGenerator.types.storeItem', + blog: 'website.linkGenerator.types.blog', + portfolio: 'website.linkGenerator.types.portfolio', + video: 'website.linkGenerator.types.video', +} + +const ANIMATION_MS = 220 + +interface GenerateWebsiteLinkModalProps { + open: boolean + onClose: () => void + onPick: (url: string) => void +} + +interface GenerateWebsiteLinkButtonProps { + onPick: (url: string) => void + disabled?: boolean +} + +async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) { + switch (kind) { + case 'blog': + return listBlogCategories(signal) + case 'portfolio': + return listPortfolioCategories(signal) + case 'video': + return listVideoCategories(signal) + case 'product': + case 'storeItem': + return listProductCategories(signal) + } +} + +export function GenerateWebsiteLinkButton({ onPick, disabled = false }: GenerateWebsiteLinkButtonProps) { + const t = useT() + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(false)} + onPick={(url) => { + onPick(url) + setOpen(false) + }} + /> + + ) +} + +export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebsiteLinkModalProps) { + const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [kind, setKind] = useState('product') + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setKind('product') + setCategories([]) + setCategoryId('') + setError('') + } 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') return + e.preventDefault() + e.stopImmediatePropagation() + onClose() + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [mounted, closing, onClose]) + + useEffect(() => { + if (!open) return + + const controller = new AbortController() + setLoading(true) + setError('') + setCategoryId('') + setCategories([]) + + void loadCategories(kind, controller.signal) + .then((items) => { + setCategories(items) + }) + .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('website.linkGenerator.errorLoad')) + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + + return () => controller.abort() + }, [open, kind, t]) + + const uiCategories = useMemo( + () => categories.map(mapProductCategoryToUi), + [categories], + ) + + const selected = categories.find((category) => category.id === categoryId) + const preview = selected ? buildWebsiteCategoryUrl(kind, selected) : '' + + if (!mounted) return null + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + e.stopPropagation() + if (!preview) return + onPick(preview) + } + + return createPortal( +
e.target === e.currentTarget && onClose()} + role="presentation" + > +
+
+
+ +

{t('website.linkGenerator.subtitle')}

+
+ +
+ +
e.stopPropagation()} + > +
+ + +
+ +
+ + {loading ? ( +

{t('website.linkGenerator.loading')}

+ ) : uiCategories.length === 0 ? ( +

{t('website.linkGenerator.empty')}

+ ) : ( + + )} +
+ + {preview ? ( +

+ {preview} +

+ ) : null} + + {error ?

{error}

: null} + +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/SearchableSelect.tsx b/apps/business/src/components/SearchableSelect.tsx index 9e77a7f..5b6b2f8 100644 --- a/apps/business/src/components/SearchableSelect.tsx +++ b/apps/business/src/components/SearchableSelect.tsx @@ -10,6 +10,7 @@ interface SearchableSelectProps { value: string onChange: (value: string) => void placeholder?: string + allowEmpty?: boolean } function categoryLabel(cat: FlatCategory, isFa: boolean) { @@ -28,6 +29,7 @@ export function SearchableSelect({ value, onChange, placeholder, + allowEmpty = true, }: SearchableSelectProps) { const t = useT() const { locale } = useLocale() @@ -104,15 +106,17 @@ export function SearchableSelect({ {open && (
    -
  • - -
  • + {allowEmpty ? ( +
  • + +
  • + ) : null} {filtered.length === 0 ? (
  • {t('categories.select.empty')}
  • ) : ( diff --git a/apps/business/src/components/Tooltip.module.css b/apps/business/src/components/Tooltip.module.css index 9c6f04f..050f66e 100644 --- a/apps/business/src/components/Tooltip.module.css +++ b/apps/business/src/components/Tooltip.module.css @@ -11,9 +11,13 @@ font-family: var(--font-ui); font-size: 12px; font-weight: 500; - line-height: 1.3; + line-height: 1.55; color: var(--text-primary); - white-space: nowrap; + white-space: pre-line; + overflow-wrap: anywhere; + word-break: break-word; + max-width: min(90vw, 420px); + text-align: start; pointer-events: none; opacity: 0; visibility: hidden; diff --git a/apps/business/src/components/Tooltip.tsx b/apps/business/src/components/Tooltip.tsx index 0d4570b..5094cee 100644 --- a/apps/business/src/components/Tooltip.tsx +++ b/apps/business/src/components/Tooltip.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState, type ReactElement } from 'react' +import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react' import { createPortal } from 'react-dom' import styles from './Tooltip.module.css' interface TooltipProps { - label: string + label: ReactNode children: ReactElement } diff --git a/apps/business/src/components/WebsiteStaticImageGallery.module.css b/apps/business/src/components/WebsiteStaticImageGallery.module.css index 5b78531..273b669 100644 --- a/apps/business/src/components/WebsiteStaticImageGallery.module.css +++ b/apps/business/src/components/WebsiteStaticImageGallery.module.css @@ -83,21 +83,55 @@ text-overflow: ellipsis; } -.removeBtn { - flex-shrink: 0; - width: 30px; - height: 30px; +.controls { display: flex; align-items: center; - justify-content: center; - border-radius: 8px; - color: var(--text-secondary); - transition: background 0.2s, color 0.2s; + flex-shrink: 0; } -.removeBtn:hover { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; +.controlGroup { + display: flex; + align-items: center; + gap: 2px; +} + +.controlDivider { + width: 1px; + height: 16px; + margin-inline: 8px; + background: rgba(148, 163, 184, 0.35); + flex-shrink: 0; +} + +.hintIcon { + cursor: default; +} + +.captionTip { + display: grid; + grid-template-columns: auto auto; + column-gap: 12px; + row-gap: 8px; + justify-content: start; + align-items: baseline; +} + +.captionRow { + display: contents; +} + +.captionKey { + font-size: 11px; + font-weight: 400; + color: var(--text-muted); + white-space: nowrap; +} + +.captionValue { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + text-align: start; } .addCard { diff --git a/apps/business/src/components/WebsiteStaticImageGallery.tsx b/apps/business/src/components/WebsiteStaticImageGallery.tsx index f105965..ae8f552 100644 --- a/apps/business/src/components/WebsiteStaticImageGallery.tsx +++ b/apps/business/src/components/WebsiteStaticImageGallery.tsx @@ -1,9 +1,10 @@ -import { Plus, Trash2 } from 'lucide-react' +import { Info, Link2, Pencil, Plus, Trash2 } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import type { WebsiteStaticImageItem } from '../types/websiteStaticImage' import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio' import { Tooltip } from './Tooltip' +import controlStyles from './CategoryRow.module.css' import styles from './WebsiteStaticImageGallery.module.css' interface WebsiteStaticImageGalleryProps { @@ -75,6 +76,14 @@ export function WebsiteStaticImageGallery({ : isExtraAdd ? addLabel : replaceLabel + const titleFa = image?.titleFa?.trim() || '' + const titleEn = (image?.titleEn || image?.title)?.trim() || '' + const subtext = image?.subtext?.trim() || '' + const captionRows = [ + titleFa ? { key: t('website.staticImages.titleFa'), value: titleFa } : null, + titleEn ? { key: t('website.staticImages.titleEn'), value: titleEn } : null, + subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null, + ].filter((row): row is { key: string; value: string } => row != null) return (
    -

    - {(locale === 'fa' - ? image.titleFa || image.title || image.titleEn - : image.titleEn || image.titleFa || image.title - )?.trim() || - image.subtext?.trim() || - image.linkUrl || - `${index + 1}`} -

    - - - +

    {index + 1}

    +
    + {captionRows.length > 0 || image.linkUrl ? ( + <> +
    + {captionRows.length > 0 ? ( + + {captionRows.map((row) => ( + + {row.key}: + {row.value} + + ))} + + } + > + + + + + ) : null} + {image.linkUrl ? ( + + + + + + ) : null} +
    + + + ) : null} +
    + + + + + + +
    +
    ) : ( diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index accf244..6f525d2 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -558,6 +558,8 @@ const en = { 'categories.tooltip.technical': 'Technical data form', 'categories.tooltip.options': 'Category options', 'categories.tooltip.remove': 'Remove category', + 'categories.tooltip.expand': 'Expand', + 'categories.tooltip.collapse': 'Collapse', 'categories.modal.parent': 'Parent Category', 'categories.modal.nameFa': 'Name (FA)', 'categories.modal.nameEn': 'Name (EN)', @@ -1330,6 +1332,22 @@ const en = { 'website.sliders.modal.linkOptional': 'Link URL (optional)', 'website.sliders.modal.addSlide': 'Add slide', + 'website.linkGenerator.generate': 'Generate', + 'website.linkGenerator.title': 'Generate link', + 'website.linkGenerator.subtitle': 'Pick a content type, then a category. The link is inserted into the field.', + 'website.linkGenerator.type': 'Type', + 'website.linkGenerator.category': 'Category', + 'website.linkGenerator.pickCategory': 'Search categories...', + 'website.linkGenerator.insert': 'Insert link', + 'website.linkGenerator.loading': 'Loading categories...', + 'website.linkGenerator.empty': 'No categories for this type yet.', + 'website.linkGenerator.errorLoad': 'Unable to load categories.', + 'website.linkGenerator.types.product': 'Product', + 'website.linkGenerator.types.storeItem': 'Store item', + 'website.linkGenerator.types.blog': 'Blog', + 'website.linkGenerator.types.portfolio': 'Portfolio', + 'website.linkGenerator.types.video': 'Video', + 'website.staticImages.subtitle': 'Websites load these by key from GET /tenants/{domain}/website/static-images', 'website.staticImages.loading': 'Loading static images...', @@ -1355,6 +1373,8 @@ const en = { 'Delete slot {label}? The website placeholder will have no image until you add one again.', 'website.staticImages.addImage': 'Add image', 'website.staticImages.replaceImage': 'Replace image', + 'website.staticImages.editImage': 'Edit image', + 'website.staticImages.imageInfo': 'Image details', 'website.staticImages.removeImage': 'Remove image', 'website.staticImages.removeImageTitle': 'Remove image', 'website.staticImages.removeImageMessage': 'Remove this image from the slot?', @@ -2472,6 +2492,8 @@ const fa: Record = { 'categories.tooltip.technical': 'فرم اطلاعات فنی', 'categories.tooltip.options': 'گزینه‌های دسته‌بندی', 'categories.tooltip.remove': 'حذف دسته‌بندی', + 'categories.tooltip.expand': 'باز کردن', + 'categories.tooltip.collapse': 'بستن', 'categories.modal.parent': 'دسته والد', 'categories.modal.nameFa': 'نام (فارسی)', 'categories.modal.nameEn': 'نام (انگلیسی)', @@ -3245,6 +3267,22 @@ const fa: Record = { 'website.sliders.modal.linkOptional': 'آدرس لینک (اختیاری)', 'website.sliders.modal.addSlide': 'افزودن اسلاید', + 'website.linkGenerator.generate': 'ساخت لینک', + 'website.linkGenerator.title': 'ساخت لینک', + 'website.linkGenerator.subtitle': 'نوع محتوا را انتخاب کنید، سپس یک دسته‌بندی. لینک در فیلد درج می‌شود.', + 'website.linkGenerator.type': 'نوع', + 'website.linkGenerator.category': 'دسته‌بندی', + 'website.linkGenerator.pickCategory': 'جستجوی دسته‌بندی‌ها...', + 'website.linkGenerator.insert': 'درج لینک', + 'website.linkGenerator.loading': 'در حال بارگذاری دسته‌بندی‌ها...', + 'website.linkGenerator.empty': 'هنوز دسته‌بندی‌ای برای این نوع نیست.', + 'website.linkGenerator.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', + 'website.linkGenerator.types.product': 'محصول', + 'website.linkGenerator.types.storeItem': 'آیتم فروشگاه', + 'website.linkGenerator.types.blog': 'بلاگ', + 'website.linkGenerator.types.portfolio': 'نمونه کار', + 'website.linkGenerator.types.video': 'ویدیو', + 'website.staticImages.subtitle': 'وب‌سایت‌ها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images می‌خوانند', 'website.staticImages.loading': 'در حال بارگذاری تصاویر ثابت...', @@ -3270,6 +3308,8 @@ const fa: Record = { 'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جای‌نگهدار وب‌سایت خالی می‌ماند.', 'website.staticImages.addImage': 'افزودن تصویر', 'website.staticImages.replaceImage': 'جایگزینی تصویر', + 'website.staticImages.editImage': 'ویرایش تصویر', + 'website.staticImages.imageInfo': 'جزئیات تصویر', 'website.staticImages.removeImage': 'حذف تصویر', 'website.staticImages.removeImageTitle': 'حذف تصویر', 'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟', diff --git a/apps/business/src/utils/categories.ts b/apps/business/src/utils/categories.ts index 949e616..09125b0 100644 --- a/apps/business/src/utils/categories.ts +++ b/apps/business/src/utils/categories.ts @@ -36,3 +36,16 @@ export function getChildren(categories: Category[], parentId: string | null): Ca export function hasChildren(categories: Category[], id: string): boolean { return categories.some((c) => c.parentId === id) } + +export function getAncestorIds(categories: Category[], id: string): string[] { + const byId = new Map(categories.map((category) => [category.id, category])) + const ids: string[] = [] + let current = byId.get(id) + + while (current?.parentId) { + ids.push(current.parentId) + current = byId.get(current.parentId) + } + + return ids +} diff --git a/apps/business/src/utils/websiteLinks.ts b/apps/business/src/utils/websiteLinks.ts new file mode 100644 index 0000000..a1478c0 --- /dev/null +++ b/apps/business/src/utils/websiteLinks.ts @@ -0,0 +1,37 @@ +import { getBusinessDomain } from '../lib/config' + +export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' + +export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [ + 'product', + 'storeItem', + 'blog', + 'portfolio', + 'video', +] + +export const WEBSITE_LINK_KIND_PATH: Record = { + product: '/products', + storeItem: '/store-items', + blog: '/blogs', + portfolio: '/portfolios', + video: '/videos', +} + +export function getWebsiteOrigin(domain = getBusinessDomain()): string { + const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '') + const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:' + return `${protocol}//${host}` +} + +export function buildWebsiteCategoryUrl( + kind: WebsiteLinkKind, + category: { id: string; slug?: string | null }, + domain = getBusinessDomain(), +): string { + const path = WEBSITE_LINK_KIND_PATH[kind] + const query = category.slug + ? `category=${encodeURIComponent(category.slug)}` + : `categoryId=${encodeURIComponent(category.id)}` + return `${getWebsiteOrigin(domain)}${path}?${query}` +}