Files
dashboards/apps/business/src/components/GenerateWebsiteLinkModal.tsx
T

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