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:
co-authored by
Cursor
parent
b9024ad77a
commit
e0e68de30f
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user