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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-17 00:30:01 +03:30
co-authored by Cursor
parent b9024ad77a
commit e0e68de30f
15 changed files with 1164 additions and 77 deletions
@@ -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;
}
@@ -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}
>
<div className={modalStyles.field}>
<label>{t('website.sliders.modal.image')}</label>
<ImageCropper
value={image}
onChange={setImage}
aspect={aspect}
uploadLabel={t('website.sliders.modal.upload')}
hint={hint ?? t('website.sliders.modal.hint')}
changeLabel={t('website.sliders.modal.changeImage')}
/>
</div>
<div className={styles.fields}>
<div className={modalStyles.field}>
<label>{t('website.sliders.modal.image')}</label>
<ImageCropper
value={image}
onChange={setImage}
aspect={aspect}
uploadLabel={t('website.sliders.modal.upload')}
hint={hint ?? t('website.sliders.modal.hint')}
changeLabel={t('website.sliders.modal.changeImage')}
/>
</div>
{captionMode === 'bilingual' ? (
<>
@@ -197,15 +199,20 @@ export function AddWebsiteSliderSlideModal({
<div className={modalStyles.field}>
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
<input
id="slide-link"
type="url"
dir="ltr"
placeholder="https://example.com/promo"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
disabled={isSubmitting}
/>
<div className={styles.linkRow}>
<input
id="slide-link"
type="text"
inputMode="url"
dir="ltr"
placeholder="https://example.com/promo"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
disabled={isSubmitting}
/>
<GenerateWebsiteLinkButton onPick={setLinkUrl} disabled={isSubmitting} />
</div>
</div>
</div>
<div className={`${modalStyles.actions} ${styles.actionsBar}`}>
@@ -227,8 +234,7 @@ export function AddWebsiteSliderSlideModal({
</div>
</form>
</div>
</div>
,
</div>,
document.body,
)
}
@@ -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;
}
@@ -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;
}
@@ -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<string> | null {
const q = query.trim()
if (!q) return null
const matched = categories.filter((category) => categoryMatches(category, q))
const visible = new Set<string>()
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<string>
visibleIds: Set<string> | 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 (
<div key={category.id}>
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={styles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggle(category.id)}
>
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={16} />
</span>
</button>
</Tooltip>
) : (
<span className={styles.toggleSpacer} />
)}
<button
type="button"
className={styles.selectBtn}
onClick={() => onSelect(category.id)}
>
<span className={styles.name}>{primary}</span>
{secondary ? (
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
· {secondary}
</span>
) : null}
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
onToggle={onToggle}
onSelect={onSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
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<Set<string>>(() => new Set())
const containerRef = useRef<HTMLDivElement>(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 (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
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 ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<div className={styles.panel}>
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
value={value}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
onToggle={handleToggle}
onSelect={selectOption}
/>
)}
</div>
) : null}
</div>
)
}
@@ -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;
}
@@ -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<WebsiteLinkKind, BusinessMessageKey> = {
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 (
<>
<button
type="button"
className={styles.generateBtn}
onClick={() => setOpen(true)}
disabled={disabled}
>
<Link2 size={15} />
{t('website.linkGenerator.generate')}
</button>
<GenerateWebsiteLinkModal
open={open}
onClose={() => 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<WebsiteLinkKind>('product')
const [categories, setCategories] = useState<ProductCategory[]>([])
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<HTMLFormElement>) {
e.preventDefault()
e.stopPropagation()
if (!preview) return
onPick(preview)
}
return createPortal(
<div
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="generate-link-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h3 id="generate-link-title" className={modalStyles.title}>
{t('website.linkGenerator.title')}
</h3>
<p className={styles.subtitle}>{t('website.linkGenerator.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form
className={`${modalStyles.form} ${styles.form}`}
onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()}
>
<div className={modalStyles.field}>
<label htmlFor="generate-link-type">{t('website.linkGenerator.type')}</label>
<select
id="generate-link-type"
value={kind}
onChange={(e) => setKind(e.target.value as WebsiteLinkKind)}
>
{WEBSITE_LINK_KINDS.map((item) => (
<option key={item} value={item}>
{t(KIND_LABEL[item])}
</option>
))}
</select>
</div>
<div className={`${modalStyles.field} ${styles.treeField}`}>
<label htmlFor="generate-link-category">{t('website.linkGenerator.category')}</label>
{loading ? (
<p className={styles.status}>{t('website.linkGenerator.loading')}</p>
) : uiCategories.length === 0 ? (
<p className={styles.status}>{t('website.linkGenerator.empty')}</p>
) : (
<CategoryTreeSelect
id="generate-link-category"
categories={uiCategories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')}
/>
)}
</div>
{preview ? (
<p className={styles.preview} dir="ltr">
{preview}
</p>
) : null}
{error ? <p className={styles.error}>{error}</p> : null}
<div className={`${modalStyles.actions} ${styles.actions}`}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
{t('products.form.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
disabled={!preview}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!preview) return
onPick(preview)
}}
>
{t('website.linkGenerator.insert')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -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 && (
<ul className={styles.dropdown} role="listbox">
<li>
<button
type="button"
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
onClick={() => selectOption('')}
>
{t('categories.select.none')}
</button>
</li>
{allowEmpty ? (
<li>
<button
type="button"
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
onClick={() => selectOption('')}
>
{t('categories.select.none')}
</button>
</li>
) : null}
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('categories.select.empty')}</li>
) : (
@@ -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;
+2 -2
View File
@@ -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
}
@@ -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 {
@@ -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 (
<div
@@ -103,25 +112,71 @@ export function WebsiteStaticImageGallery({
/>
</button>
<div className={styles.footer}>
<p className={styles.title}>
{(locale === 'fa'
? image.titleFa || image.title || image.titleEn
: image.titleEn || image.titleFa || image.title
)?.trim() ||
image.subtext?.trim() ||
image.linkUrl ||
`${index + 1}`}
</p>
<Tooltip label={removeLabel}>
<button
type="button"
className={styles.removeBtn}
onClick={() => onRemove(image)}
aria-label={removeLabel}
>
<Trash2 size={16} />
</button>
</Tooltip>
<p className={styles.title}>{index + 1}</p>
<div className={styles.controls}>
{captionRows.length > 0 || image.linkUrl ? (
<>
<div className={styles.controlGroup}>
{captionRows.length > 0 ? (
<Tooltip
label={
<span className={styles.captionTip}>
{captionRows.map((row) => (
<span key={row.key} className={styles.captionRow}>
<span className={styles.captionKey}>{row.key}:</span>
<span className={styles.captionValue}>{row.value}</span>
</span>
))}
</span>
}
>
<span
className={`${controlStyles.controlBtn} ${styles.hintIcon}`}
role="img"
aria-label={t('website.staticImages.imageInfo')}
>
<Info size={16} />
</span>
</Tooltip>
) : null}
{image.linkUrl ? (
<Tooltip label={image.linkUrl}>
<span
className={`${controlStyles.controlBtn} ${styles.hintIcon}`}
role="img"
aria-label={image.linkUrl}
>
<Link2 size={16} />
</span>
</Tooltip>
) : null}
</div>
<span className={styles.controlDivider} aria-hidden />
</>
) : null}
<div className={styles.controlGroup}>
<Tooltip label={t('website.staticImages.editImage')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => onAddAt(index)}
aria-label={t('website.staticImages.editImage')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={removeLabel}>
<button
type="button"
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
onClick={() => onRemove(image)}
aria-label={removeLabel}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</div>
</div>
</article>
) : (
+40
View File
@@ -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<MessageKey, string> = {
'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<MessageKey, string> = {
'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<MessageKey, string> = {
'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جای‌نگهدار وب‌سایت خالی می‌ماند.',
'website.staticImages.addImage': 'افزودن تصویر',
'website.staticImages.replaceImage': 'جایگزینی تصویر',
'website.staticImages.editImage': 'ویرایش تصویر',
'website.staticImages.imageInfo': 'جزئیات تصویر',
'website.staticImages.removeImage': 'حذف تصویر',
'website.staticImages.removeImageTitle': 'حذف تصویر',
'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟',
+13
View File
@@ -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
}
+37
View File
@@ -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<WebsiteLinkKind, string> = {
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}`
}