325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
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 { listWorkshopCategories } from '../services/workshopCategoryService'
|
|
import type { ProductCategory } from '../types/category'
|
|
import {
|
|
WEBSITE_LINK_KINDS,
|
|
buildWebsiteCategoryUrl,
|
|
buildWebsiteTagsUrl,
|
|
type WebsiteLinkFilter,
|
|
type WebsiteLinkKind,
|
|
} from '../utils/websiteLinks'
|
|
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
|
import { TagInput } from './TagInput'
|
|
import modalStyles from './CategoryModal.module.css'
|
|
import styles from './GenerateWebsiteLinkModal.module.css'
|
|
|
|
export type { WebsiteLinkKind }
|
|
export { buildWebsiteCategoryUrl, buildWebsiteTagsUrl }
|
|
|
|
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',
|
|
workshop: 'website.linkGenerator.types.workshop',
|
|
}
|
|
|
|
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 'workshop':
|
|
return listWorkshopCategories(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 [filterBy, setFilterBy] = useState<WebsiteLinkFilter>('category')
|
|
const [categories, setCategories] = useState<ProductCategory[]>([])
|
|
const [categoryId, setCategoryId] = useState('')
|
|
const [tags, setTags] = useState<string[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true)
|
|
setClosing(false)
|
|
setKind('product')
|
|
setFilterBy('category')
|
|
setCategories([])
|
|
setCategoryId('')
|
|
setTags([])
|
|
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 || filterBy !== 'category') 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, filterBy, t])
|
|
|
|
const uiCategories = useMemo(
|
|
() => categories.map(mapProductCategoryToUi),
|
|
[categories],
|
|
)
|
|
|
|
const selected = categories.find((category) => category.id === categoryId)
|
|
const preview =
|
|
filterBy === 'category'
|
|
? selected
|
|
? buildWebsiteCategoryUrl(kind, selected)
|
|
: ''
|
|
: buildWebsiteTagsUrl(kind, tags)
|
|
|
|
if (!mounted) return null
|
|
|
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (!preview) return
|
|
onPick(preview)
|
|
}
|
|
|
|
function handleFilterChange(next: WebsiteLinkFilter) {
|
|
setFilterBy(next)
|
|
setError('')
|
|
if (next === 'tag') {
|
|
setCategoryId('')
|
|
} else {
|
|
setTags([])
|
|
}
|
|
}
|
|
|
|
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}>
|
|
<label htmlFor="generate-link-filter">{t('website.linkGenerator.filterBy')}</label>
|
|
<select
|
|
id="generate-link-filter"
|
|
value={filterBy}
|
|
onChange={(e) => handleFilterChange(e.target.value as WebsiteLinkFilter)}
|
|
>
|
|
<option value="category">{t('website.linkGenerator.category')}</option>
|
|
<option value="tag">{t('website.linkGenerator.tag')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{filterBy === 'category' ? (
|
|
<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')}
|
|
allowEmpty={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className={modalStyles.field}>
|
|
<label htmlFor="generate-link-tags">{t('website.linkGenerator.tags')}</label>
|
|
<TagInput
|
|
tags={tags}
|
|
onChange={setTags}
|
|
placeholder={t('website.linkGenerator.tagsPlaceholder')}
|
|
/>
|
|
<p className={styles.hint}>{t('website.linkGenerator.tagsHint')}</p>
|
|
</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,
|
|
)
|
|
}
|