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
@@ -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,
)
}