Files
dashboards/apps/business/src/services/blogService.ts
T
Alireza HassaniandCursor c2ede07e73 Add website schema settings, SEO meta overrides, and resilient bulk SMS.
Business admins can configure site-wide Schema.org and optional per-item SEO title/description; mass SMS continues on failures with an end report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 23:30:23 +03:30

231 lines
6.0 KiB
TypeScript

import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type { Blog, BlogAuthor, BlogDetail, BlogPostType, BlogStatus } from '../types/blog'
export const BLOGS_PER_PAGE = 12
export interface BlogApi {
id: string
businessId: string
title: string
slug: string
type: BlogPostType
abstract: string
mainTextHtml: string
status: BlogStatus
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
titleImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
author?: BlogAuthor | null
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface BlogsListResponse {
items: BlogApi[]
total: number
page: number
pageSize: number
}
export interface ListBlogsParams {
page?: number
pageSize?: number
status?: BlogStatus
type?: BlogPostType
categoryId?: string
title?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/blogs${suffix}`
}
export interface CreateBlogPayload {
title: string
type: BlogPostType
abstract?: string
mainTextHtml?: string
categoryId?: string
status?: BlogStatus
featuredMediaId?: string
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export interface UpdateBlogPayload {
title?: string
type?: BlogPostType
abstract?: string | null
mainTextHtml?: string | null
categoryId?: string | null
status?: BlogStatus
featuredMediaId?: string | null
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export function resolveBlogTitleImageUrl(blog: BlogApi): string | null {
return blog.titleImageUrl?.trim() || extractFirstImageUrl(blog.mainTextHtml) || null
}
export function mapBlogApiToUi(blog: BlogApi): Blog {
return {
id: blog.id,
title: blog.title,
slug: blog.slug,
type: blog.type,
abstract: blog.abstract,
status: blog.status,
categoryId: blog.categoryId,
categoryName: blog.categoryName,
tags: blog.tags,
titleImageUrl: resolveBlogTitleImageUrl(blog),
commentCount: blog.commentCount,
author: blog.author ?? null,
publishedAt: blog.publishedAt,
createdAt: blog.createdAt,
updatedAt: blog.updatedAt,
}
}
export function mapBlogDetailFromApi(blog: BlogApi): BlogDetail {
return {
...mapBlogApiToUi(blog),
mainTextHtml: blog.mainTextHtml,
author: blog.author ?? null,
}
}
export function formatBlogAuthor(author: BlogAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatBlogDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatBlogCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function isBlogVerified(blog: Pick<Blog, 'status'>): boolean {
return blog.status === 'published'
}
export async function setBlogVerified(blogId: string, verified: boolean) {
return updateBlog(blogId, { status: verified ? 'published' : 'draft' })
}
export function mapBlogApiToFormState(blog: BlogApi) {
return {
title: blog.title,
type: blog.type,
abstract: blog.abstract,
mainTextHtml: blog.mainTextHtml,
categoryId: blog.categoryId ?? '',
tags: blog.tags,
seoMetaTitle: blog.seoMetaTitle ?? '',
seoMetaDescription: blog.seoMetaDescription ?? '',
titleImage: resolveBlogTitleImageUrl(blog),
featuredMediaId: blog.featuredMediaId,
}
}
export async function listBlogs(
page = 1,
pageSize = BLOGS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListBlogsParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.type) q.set('type', filters.type)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<BlogsListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapBlogApiToUi),
}
}
export async function getBlog(blogId: string, signal?: AbortSignal) {
const data = await apiRequest<{ blog: BlogApi }>(businessPath(`/${blogId}`), {
auth: true,
signal,
})
return data.blog
}
export async function getBlogDetail(blogId: string, signal?: AbortSignal) {
const blog = await getBlog(blogId, signal)
return mapBlogDetailFromApi(blog)
}
export async function createBlog(payload: CreateBlogPayload) {
return apiRequest<{ message: string; blog: BlogApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateBlog(blogId: string, payload: UpdateBlogPayload) {
return apiRequest<{ message: string; blog: BlogApi }>(businessPath(`/${blogId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteBlog(blogId: string) {
return apiRequest<{ message: string }>(businessPath(`/${blogId}`), {
method: 'DELETE',
auth: true,
})
}