admin/validation/blogArticles.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

101 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import { z } from 'zod'
// Same convention as validation/eventCategories.ts — slugs are URL/API
// identifiers, always Latin, no Persian-digit conversion.
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
export const BLOG_ARTICLE_CATEGORIES = [
{ code: 'attendee-guide', name: 'راهنمای شرکت‌کنندگان' },
{ code: 'hosting', name: 'راهنمای میزبانی' },
{ code: 'city-guides', name: 'راهنمای شهرها' },
{ code: 'event-selection', name: 'انتخاب رویداد' },
] as const
export const analyzeArticleBody = (bodyHtml: string) => {
const plainText = bodyHtml
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const wordCount = plainText ? plainText.split(' ').length : 0
const headingCount = (bodyHtml.match(/<h[23](?:\s[^>]*)?>/gi) ?? []).length
const internalLinkCount = (bodyHtml.match(/<a\s[^>]*href=["']\/(?!\/)[^"']*["']/gi) ?? []).length
const images = bodyHtml.match(/<img\s[^>]*>/gi) ?? []
const imagesWithoutAlt = images.filter((tag) => !/\salt=["'][^"']+["']/i.test(tag)).length
return { wordCount, headingCount, internalLinkCount, imagesWithoutAlt }
}
// SEO/char-limited field limits — also referenced by ArticleFormModal's
// SeoCharCounterHint call sites, so the UI hint and the zod schema can
// never drift apart.
export const ARTICLE_TITLE_MAX = 52
export const ARTICLE_EXCERPT_MIN = 70
export const ARTICLE_EXCERPT_MAX = 160
export const ARTICLE_META_TITLE_MAX = 52
export const ARTICLE_META_DESCRIPTION_MIN = 70
export const ARTICLE_META_DESCRIPTION_MAX = 160
export const ArticleFormValidation = z
.object({
slug: z
.string()
.trim()
.min(1, 'اسلاگ را وارد کنید')
.max(160, 'اسلاگ حداکثر ۱۶۰ کاراکتر است')
.regex(SLUG_PATTERN, 'اسلاگ فقط می‌تواند شامل حروف انگلیسی کوچک، عدد و خط تیره باشد'),
title: z
.string()
.trim()
.min(1, 'عنوان را وارد کنید')
.max(ARTICLE_TITLE_MAX, 'عنوان حداکثر ۵۲ کاراکتر است تا با پسوند سایت زیر ۶۰ کاراکتر بماند'),
excerpt: z
.string()
.trim()
.min(ARTICLE_EXCERPT_MIN, 'برای استفاده‌ی کامل در نتایج گوگل، توضیح باید حداقل ۷۰ کاراکتر باشد')
.max(ARTICLE_EXCERPT_MAX, 'توضیح حداکثر ۱۶۰ کاراکتر است'),
metaTitle: z
.string()
.trim()
.max(ARTICLE_META_TITLE_MAX, 'عنوان SEO با احتساب پسوند برند باید حداکثر ۵۲ کاراکتر باشد')
.optional()
.or(z.literal('')),
metaDescription: z
.string()
.trim()
.min(ARTICLE_META_DESCRIPTION_MIN, 'توضیحات SEO باید حداقل ۷۰ کاراکتر باشد')
.max(ARTICLE_META_DESCRIPTION_MAX, 'توضیحات SEO حداکثر ۱۶۰ کاراکتر است')
.optional()
.or(z.literal('')),
categorySlug: z.enum(['attendee-guide', 'hosting', 'city-guides', 'event-selection'], {
message: 'یک دسته‌بندی انتخاب کنید',
}),
categoryName: z.string().trim().min(1),
// The Select control (ChoiceFieldControl) only ever writes back strings,
// even for numeric ids — kept as string here and converted to
// number | undefined right before the API call in ArticleFormModal.
cityId: z.string().optional().or(z.literal('')),
eventCategoryId: z.string().optional().or(z.literal('')),
bodyHtml: z.string().trim().min(1, 'متن مقاله را وارد کنید'),
featuredImageUrl: z.union([z.literal(''), z.url('آدرس تصویر معتبر نیست')]).optional(),
isFeatured: z.boolean(),
isPublished: z.boolean(),
scheduledDate: z.string().optional().or(z.literal('')),
scheduledTime: z.number().min(0).max(1439),
})
.superRefine((values, ctx) => {
if (!values.scheduledDate || values.isPublished) return
const scheduledAt = new Date(values.scheduledDate)
scheduledAt.setHours(Math.floor(values.scheduledTime / 60), values.scheduledTime % 60, 0, 0)
if (!Number.isFinite(scheduledAt.getTime()) || scheduledAt.getTime() <= Date.now()) {
ctx.addIssue({
code: 'custom',
message: 'زمان انتشار باید در آینده باشد',
path: ['scheduledDate'],
})
}
})
export type ArticleFormValues = z.infer<typeof ArticleFormValidation>