admin/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
alisaza 82593dc18c refactor(events): update event management components and remove unused files
- 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.
2026-09-07 18:09:55 +03:30

434 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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