'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() const categorySlug = useWatch({ 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() const bodyHtml = useWatch({ 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 (
{ setValue('bodyHtml', value, { shouldDirty: true }) }} />
{checks.map((check) => (
{check.ok ? '✓' : '○'} {check.label}
))}
) } const FeaturedImageField = () => { const { setValue } = useFormContext() const featuredImageUrl = useWatch({ name: 'featuredImageUrl' }) return ( { setValue('featuredImageUrl', url, { shouldDirty: true }) }} /> ) } const ScheduledPublishingFields = () => { const { setValue } = useFormContext() const isPublished = useWatch({ name: 'isPublished' }) const scheduledDate = useWatch({ name: 'scheduledDate' }) if (isPublished) return null return (

برای نگه‌داشتن مقاله به‌صورت پیش‌نویس، تاریخ را خالی بگذارید. با انتخاب تاریخ و ساعت، مقاله خودکار منتشر می‌شود.

{scheduledDate ? ( ) : null}
) } interface ArticleFormModalProps { isOpen: boolean onOpenChange: (isOpen: boolean) => void article?: BlogArticle onSuccess: () => void } const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleFormModalProps) => { const [cities, setCities] = useState([]) 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 (
max={ARTICLE_TITLE_MAX} name="title" /> } generalType="input" label="عنوان" name="title" placeholder="راهنمای پیدا کردن رویداد در ..." /> max={ARTICLE_EXCERPT_MAX} min={ARTICLE_EXCERPT_MIN} name="excerpt" /> } generalType="textarea" label="خلاصه (توضیح در فهرست و meta description)" name="excerpt" />
[]} selectValue="name" />
max={ARTICLE_META_TITLE_MAX} name="metaTitle" /> } generalType="input" label="عنوان SEO" name="metaTitle" /> max={ARTICLE_META_DESCRIPTION_MAX} min={ARTICLE_META_DESCRIPTION_MIN} name="metaDescription" /> } generalType="textarea" label="توضیحات SEO" name="metaDescription" />
) } export default ArticleFormModal