- Replaced `LIST_PUBLIC_CATEGORIES` with `LIST_ADMIN_CATEGORIES_FLAT` in `ArticleFormModal.tsx`. - Removed the "Add Event" button from the dashboard page. - Simplified the `EventsPage` by removing the button and adjusting the layout. - Cleaned up the `AdminEventEditPage` by removing unnecessary props. - Deleted unused layout and page files related to event creation. - Updated `AdminAuthContent` to use `useAdminCitiesQuery` instead of `useDiscoveryCitiesQuery`. - Refactored `EventGuestListAccessPanel` to remove the `accessMode` prop and adjust API calls accordingly. - Removed several unused components and tests related to event creation, enhancing project maintainability.
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { useEffect, useState } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { FormProvider, useFormContext, useWatch } from 'react-hook-form'
|
||
|
||
import { addToast } from '@/lib/toast'
|
||
import { Accordion, AccordionItem } from '@/components/heroui/Accordion'
|
||
import ArticleFeaturedImageUploader from '@/app/(dashboard)/blog-articles/_components/ArticleFeaturedImageUploader'
|
||
import Modal from '@/components/modals/Modal'
|
||
import Button from '@/components/formElements/Button'
|
||
import Input from '@/components/formElements/Input'
|
||
import { AdminFormSection } from '@/components/forms/AdminFormLayout'
|
||
import { SeoCharCounterHint } from '@/components/forms/SeoCharCounterHint'
|
||
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
|
||
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
|
||
import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles'
|
||
import { fetchAllCities, type City } from '@/services/geography'
|
||
import { LIST_ADMIN_CATEGORIES_FLAT } from '@/services/eventCategories'
|
||
import {
|
||
analyzeArticleBody,
|
||
ARTICLE_EXCERPT_MAX,
|
||
ARTICLE_EXCERPT_MIN,
|
||
ARTICLE_META_DESCRIPTION_MAX,
|
||
ARTICLE_META_DESCRIPTION_MIN,
|
||
ARTICLE_META_TITLE_MAX,
|
||
ARTICLE_TITLE_MAX,
|
||
ArticleFormValidation,
|
||
BLOG_ARTICLE_CATEGORIES,
|
||
type ArticleFormValues,
|
||
} from '@/validation/blogArticles'
|
||
|
||
const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false })
|
||
|
||
const NO_CITY_OPTION = { id: '', name: 'بدون شهر خاص' }
|
||
const NO_EVENT_CATEGORY_OPTION = { id: '', name: 'بدون دستهبندی رویداد خاص' }
|
||
|
||
const EMPTY_VALUES: ArticleFormValues = {
|
||
slug: '',
|
||
title: '',
|
||
excerpt: '',
|
||
metaTitle: '',
|
||
metaDescription: '',
|
||
categorySlug: 'attendee-guide',
|
||
categoryName: BLOG_ARTICLE_CATEGORIES[0].name,
|
||
cityId: '',
|
||
eventCategoryId: '',
|
||
bodyHtml: '',
|
||
featuredImageUrl: '',
|
||
isFeatured: false,
|
||
isPublished: false,
|
||
scheduledDate: '',
|
||
scheduledTime: 0,
|
||
}
|
||
|
||
const splitScheduledAt = (value: string | null | undefined) => {
|
||
if (!value) return { scheduledDate: '', scheduledTime: 0 }
|
||
|
||
const date = new Date(value)
|
||
|
||
return {
|
||
scheduledDate: Number.isFinite(date.getTime()) ? date.toISOString() : '',
|
||
scheduledTime: Number.isFinite(date.getTime()) ? date.getHours() * 60 + date.getMinutes() : 0,
|
||
}
|
||
}
|
||
|
||
const combineScheduledAt = (dateValue: string, minutes: number) => {
|
||
const date = new Date(dateValue)
|
||
|
||
date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
|
||
|
||
return date.toISOString()
|
||
}
|
||
|
||
const toFormValues = (article: BlogArticle): ArticleFormValues => ({
|
||
slug: article.slug,
|
||
title: article.title,
|
||
excerpt: article.excerpt,
|
||
metaTitle: article.metaTitle ?? '',
|
||
metaDescription: article.metaDescription ?? '',
|
||
categorySlug: article.categorySlug as ArticleFormValues['categorySlug'],
|
||
categoryName: article.categoryName,
|
||
cityId: article.city ? String(article.city.id) : '',
|
||
eventCategoryId: article.eventCategory ? String(article.eventCategory.id) : '',
|
||
bodyHtml: article.bodyHtml,
|
||
featuredImageUrl: article.featuredImageUrl ?? '',
|
||
isFeatured: article.isFeatured,
|
||
isPublished: article.isPublished,
|
||
...splitScheduledAt(article.scheduledAt),
|
||
})
|
||
|
||
const buildPayload = (values: ArticleFormValues) => ({
|
||
slug: values.slug,
|
||
title: values.title,
|
||
excerpt: values.excerpt,
|
||
metaTitle: values.metaTitle || null,
|
||
metaDescription: values.metaDescription || null,
|
||
categorySlug: values.categorySlug,
|
||
categoryName: values.categoryName,
|
||
cityId: values.cityId ? Number(values.cityId) : null,
|
||
eventCategoryId: values.eventCategoryId ? Number(values.eventCategoryId) : null,
|
||
bodyHtml: values.bodyHtml,
|
||
featuredImageUrl: values.featuredImageUrl || null,
|
||
isFeatured: values.isFeatured,
|
||
isPublished: values.isPublished,
|
||
scheduledAt: !values.isPublished && values.scheduledDate ? combineScheduledAt(values.scheduledDate, values.scheduledTime) : null,
|
||
})
|
||
|
||
/** Keeps categoryName in sync with the selected categorySlug — categoryName
|
||
* is stored on the row for display convenience but is never a field the
|
||
* admin edits directly, so it can't drift from the taxonomy label. */
|
||
const CategoryNameSync = () => {
|
||
const { setValue } = useFormContext<ArticleFormValues>()
|
||
const categorySlug = useWatch<ArticleFormValues, 'categorySlug'>({ name: 'categorySlug' })
|
||
|
||
useEffect(() => {
|
||
const match = BLOG_ARTICLE_CATEGORIES.find((item) => item.code === categorySlug)
|
||
|
||
if (match) setValue('categoryName', match.name)
|
||
}, [categorySlug, setValue])
|
||
|
||
return null
|
||
}
|
||
|
||
const ArticleBodyField = () => {
|
||
const { setValue } = useFormContext<ArticleFormValues>()
|
||
const bodyHtml = useWatch<ArticleFormValues, 'bodyHtml'>({ name: 'bodyHtml' })
|
||
const analysis = analyzeArticleBody(bodyHtml)
|
||
const checks = [
|
||
{ ok: analysis.wordCount >= 250, label: `${analysis.wordCount.toLocaleString('fa-IR')} واژه از حداقل ۲۵۰ واژه` },
|
||
{ ok: analysis.headingCount >= 2, label: `${analysis.headingCount.toLocaleString('fa-IR')} تیتر H2/H3 از حداقل ۲ تیتر` },
|
||
{ ok: analysis.internalLinkCount >= 1, label: 'حداقل یک لینک داخلی مرتبط' },
|
||
{ ok: analysis.imagesWithoutAlt === 0, label: 'تمام تصاویر داخل متن دارای alt هستند' },
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<TextEditor
|
||
required
|
||
label="متن مقاله"
|
||
value={bodyHtml}
|
||
onChange={(value) => {
|
||
setValue('bodyHtml', value, { shouldDirty: true })
|
||
}}
|
||
/>
|
||
<div
|
||
aria-live="polite"
|
||
className="grid gap-2 rounded-xl border border-secondary-40 bg-secondary-50 p-3 text-xs sm:grid-cols-2"
|
||
>
|
||
{checks.map((check) => (
|
||
<div
|
||
key={check.label}
|
||
className={check.ok ? 'text-fifth-700' : 'text-fourth-700'}
|
||
>
|
||
{check.ok ? '✓' : '○'} {check.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const FeaturedImageField = () => {
|
||
const { setValue } = useFormContext<ArticleFormValues>()
|
||
const featuredImageUrl = useWatch<ArticleFormValues, 'featuredImageUrl'>({ name: 'featuredImageUrl' })
|
||
|
||
return (
|
||
<ArticleFeaturedImageUploader
|
||
value={featuredImageUrl ?? ''}
|
||
onChange={(url) => {
|
||
setValue('featuredImageUrl', url, { shouldDirty: true })
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const ScheduledPublishingFields = () => {
|
||
const { setValue } = useFormContext<ArticleFormValues>()
|
||
const isPublished = useWatch<ArticleFormValues, 'isPublished'>({ name: 'isPublished' })
|
||
const scheduledDate = useWatch<ArticleFormValues, 'scheduledDate'>({ name: 'scheduledDate' })
|
||
|
||
if (isPublished) return null
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-xl border border-fourth-100 bg-fourth-100 p-3 sm:col-span-2">
|
||
<p className="text-xs leading-6 text-fourth-900">
|
||
برای نگهداشتن مقاله بهصورت پیشنویس، تاریخ را خالی بگذارید. با انتخاب تاریخ و ساعت، مقاله خودکار منتشر میشود.
|
||
</p>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<Input
|
||
generalType="datePicker"
|
||
label="تاریخ انتشار"
|
||
minDate={new Date().toISOString()}
|
||
name="scheduledDate"
|
||
/>
|
||
<Input
|
||
generalType="timePicker"
|
||
label="ساعت انتشار"
|
||
name="scheduledTime"
|
||
/>
|
||
</div>
|
||
{scheduledDate ? (
|
||
<Button
|
||
size="sm"
|
||
variant="light"
|
||
onClick={() => {
|
||
setValue('scheduledDate', '', { shouldDirty: true, shouldValidate: true })
|
||
}}
|
||
>
|
||
لغو زمانبندی و نگهداری بهصورت پیشنویس
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface ArticleFormModalProps {
|
||
isOpen: boolean
|
||
onOpenChange: (isOpen: boolean) => void
|
||
article?: BlogArticle
|
||
onSuccess: () => void
|
||
}
|
||
|
||
const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleFormModalProps) => {
|
||
const [cities, setCities] = useState<City[]>([])
|
||
const [eventCategories, setEventCategories] = useState<{ id: number; name: string }[]>([])
|
||
|
||
const { form, isEdit, submitting, handleSubmit } = useAdminCrudFormModal({
|
||
entity: article,
|
||
isOpen,
|
||
emptyValues: EMPTY_VALUES,
|
||
toFormValues,
|
||
resolver: zodResolver(ArticleFormValidation),
|
||
buildPayload,
|
||
create: CREATE_ARTICLE,
|
||
update: UPDATE_ARTICLE,
|
||
getId: (entity) => entity.id,
|
||
successMessage: { create: 'مقاله ایجاد شد', edit: 'مقاله ویرایش شد' },
|
||
onOpenChange,
|
||
onSuccess,
|
||
})
|
||
|
||
useEffect(() => {
|
||
fetchAllCities()
|
||
.then(setCities)
|
||
.catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' }))
|
||
|
||
void LIST_ADMIN_CATEGORIES_FLAT().then((result) => {
|
||
if (result.ok) setEventCategories(result.data.items)
|
||
})
|
||
}, [])
|
||
|
||
const cityOptions = [NO_CITY_OPTION, ...cities.map((city) => ({ id: String(city.id), name: city.name }))]
|
||
const eventCategoryOptions = [
|
||
NO_EVENT_CATEGORY_OPTION,
|
||
...eventCategories.map((category) => ({ id: String(category.id), name: category.name })),
|
||
]
|
||
|
||
return (
|
||
<Modal
|
||
acceptBtnText={isEdit ? 'ذخیره تغییرات' : 'ایجاد مقاله'}
|
||
isLoading={submitting}
|
||
isOpen={isOpen}
|
||
scrollBehavior="inside"
|
||
size="2xl"
|
||
title={isEdit ? `ویرایش «${article?.title}»` : 'مقاله جدید'}
|
||
onAccept={handleSubmit}
|
||
onOpenChange={onOpenChange}
|
||
>
|
||
<FormProvider {...form}>
|
||
<form
|
||
className="flex flex-col gap-4"
|
||
onSubmit={handleSubmit}
|
||
>
|
||
<UnsavedChangesIndicator isDirty={form.formState.isDirty} />
|
||
<CategoryNameSync />
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="عنوان، آدرس و دستهبندی مقاله"
|
||
title="اطلاعات اصلی"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
<Input
|
||
required
|
||
description={
|
||
<SeoCharCounterHint<ArticleFormValues>
|
||
max={ARTICLE_TITLE_MAX}
|
||
name="title"
|
||
/>
|
||
}
|
||
generalType="input"
|
||
label="عنوان"
|
||
name="title"
|
||
placeholder="راهنمای پیدا کردن رویداد در ..."
|
||
/>
|
||
<Input
|
||
required
|
||
description={article?.publishedAt ? 'برای حفظ اعتبار URL، اسلاگ مقاله منتشرشده قابل تغییر نیست.' : undefined}
|
||
direction="ltr"
|
||
disabled={Boolean(article?.publishedAt)}
|
||
generalType="input"
|
||
label="اسلاگ (شناسهی آدرس)"
|
||
name="slug"
|
||
placeholder="ahvaz-events-guide"
|
||
/>
|
||
<Input
|
||
required
|
||
description={
|
||
<SeoCharCounterHint<ArticleFormValues>
|
||
max={ARTICLE_EXCERPT_MAX}
|
||
min={ARTICLE_EXCERPT_MIN}
|
||
name="excerpt"
|
||
/>
|
||
}
|
||
generalType="textarea"
|
||
label="خلاصه (توضیح در فهرست و meta description)"
|
||
name="excerpt"
|
||
/>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
<Input
|
||
required
|
||
generalType="select"
|
||
label="دستهبندی بلاگ"
|
||
name="categorySlug"
|
||
selectKey="code"
|
||
selectOptions={BLOG_ARTICLE_CATEGORIES as unknown as Record<string, unknown>[]}
|
||
selectValue="name"
|
||
/>
|
||
<Input
|
||
generalType="select"
|
||
label="شهر مرتبط"
|
||
name="cityId"
|
||
selectKey="id"
|
||
selectOptions={cityOptions}
|
||
selectValue="name"
|
||
/>
|
||
<Input
|
||
generalType="select"
|
||
label="دستهبندی رویداد مرتبط"
|
||
name="eventCategoryId"
|
||
selectKey="id"
|
||
selectOptions={eventCategoryOptions}
|
||
selectValue="name"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</AdminFormSection>
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="نحوه نمایش مقاله در نتایج جستجو"
|
||
title="بهینهسازی موتور جستجو"
|
||
>
|
||
<Accordion variant="bordered">
|
||
<AccordionItem
|
||
key="seo"
|
||
aria-label="تنظیمات SEO"
|
||
subtitle="در صورت خالی بودن، عنوان و خلاصهی بالا استفاده میشود"
|
||
title="عنوان و توضیح جایگزین (اختیاری)"
|
||
>
|
||
<div className="flex flex-col gap-4 pb-2">
|
||
<Input
|
||
description={
|
||
<SeoCharCounterHint<ArticleFormValues>
|
||
max={ARTICLE_META_TITLE_MAX}
|
||
name="metaTitle"
|
||
/>
|
||
}
|
||
generalType="input"
|
||
label="عنوان SEO"
|
||
name="metaTitle"
|
||
/>
|
||
<Input
|
||
description={
|
||
<SeoCharCounterHint<ArticleFormValues>
|
||
max={ARTICLE_META_DESCRIPTION_MAX}
|
||
min={ARTICLE_META_DESCRIPTION_MIN}
|
||
name="metaDescription"
|
||
/>
|
||
}
|
||
generalType="textarea"
|
||
label="توضیحات SEO"
|
||
name="metaDescription"
|
||
/>
|
||
</div>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
</AdminFormSection>
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="تصویری که در کارتها و شبکههای اجتماعی نمایش داده میشود"
|
||
title="تصویر شاخص"
|
||
>
|
||
<FeaturedImageField />
|
||
</AdminFormSection>
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="متن اصلی مقاله"
|
||
title="بدنه مقاله"
|
||
>
|
||
<ArticleBodyField />
|
||
</AdminFormSection>
|
||
|
||
<AdminFormSection
|
||
contained={false}
|
||
description="اولویت و وضعیت انتشار مقاله"
|
||
title="تنظیمات نمایش"
|
||
>
|
||
<div className="grid gap-3 rounded-xl bg-secondary-50 p-3 sm:grid-cols-2">
|
||
<Input
|
||
generalType="switch"
|
||
label="ویژه"
|
||
name="isFeatured"
|
||
/>
|
||
<Input
|
||
generalType="switch"
|
||
label="منتشرشده"
|
||
name="isPublished"
|
||
/>
|
||
<ScheduledPublishingFields />
|
||
</div>
|
||
</AdminFormSection>
|
||
</form>
|
||
</FormProvider>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
export default ArticleFormModal
|