Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
101 lines
4.4 KiB
TypeScript
101 lines
4.4 KiB
TypeScript
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>
|