Files
dashboards/apps/business/src/services/portfolioService.ts
T
Alireza HassaniandCursor c289de5d2e Add portfolio website link field and card open-link control.
Stay on the current list page after move-to-top; only toast success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:46:13 +03:30

243 lines
6.7 KiB
TypeScript

import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type { Portfolio, PortfolioDetail, PortfolioGalleryItem, PortfolioStatus } from '../types/portfolio'
export const PORTFOLIOS_PER_PAGE = 12
export interface PortfolioApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
projectUrl: string | null
mainTextHtml: string
status: PortfolioStatus
categoryId: string | null
categoryName: string
tags: string[]
titleImageUrl: string | null
featuredMediaId: string | null
gallery: PortfolioGalleryItem[]
galleryMediaIds: string[]
sortOrder: number
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface PortfoliosListResponse {
items: PortfolioApi[]
total: number
page: number
pageSize: number
}
export interface ListPortfoliosParams {
page?: number
pageSize?: number
status?: PortfolioStatus
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}/portfolios${suffix}`
}
export interface CreatePortfolioPayload {
titleFa: string
titleEn?: string
/** @deprecated Prefer titleFa */
title?: string
abstract?: string
projectUrl?: string
mainTextHtml?: string
categoryId?: string
status?: PortfolioStatus
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
sortOrder?: number
}
export interface UpdatePortfolioPayload {
titleFa?: string
titleEn?: string | null
/** @deprecated Prefer titleFa */
title?: string
abstract?: string | null
projectUrl?: string | null
mainTextHtml?: string | null
categoryId?: string | null
status?: PortfolioStatus
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
sortOrder?: number
/** Pin to absolute top of the business portfolio list. */
moveToTop?: boolean
}
export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string | null {
const featured = portfolio.titleImageUrl?.trim()
if (featured) return featured
const galleryUrl = portfolio.gallery?.map((item) => item.url?.trim()).find(Boolean)
if (galleryUrl) return galleryUrl
return extractFirstImageUrl(portfolio.mainTextHtml) || null
}
export function resolvePortfolioExternalUrl(url: string | null | undefined): string | null {
const trimmed = url?.trim()
if (!trimmed) return null
if (/^https?:\/\//i.test(trimmed)) return trimmed
return `https://${trimmed}`
}
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
return {
id: portfolio.id,
title: portfolio.titleFa || portfolio.title,
titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? null,
slug: portfolio.slug,
abstract: portfolio.abstract,
projectUrl: portfolio.projectUrl?.trim() || null,
status: portfolio.status,
categoryId: portfolio.categoryId,
categoryName: portfolio.categoryName,
tags: portfolio.tags,
titleImageUrl: resolvePortfolioTitleImageUrl(portfolio),
sortOrder: portfolio.sortOrder ?? 0,
commentCount: portfolio.commentCount,
publishedAt: portfolio.publishedAt,
createdAt: portfolio.createdAt,
updatedAt: portfolio.updatedAt,
}
}
export function formatPortfolioCardDate(
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 formatPortfolioDate(
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 mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDetail {
return {
...mapPortfolioApiToUi(portfolio),
mainTextHtml: portfolio.mainTextHtml,
gallery: portfolio.gallery ?? [],
}
}
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
return {
titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? '',
abstract: portfolio.abstract,
mainTextHtml: portfolio.mainTextHtml,
projectUrl: portfolio.projectUrl?.trim() || '',
categoryId: portfolio.categoryId ?? '',
tags: portfolio.tags,
titleImage: resolvePortfolioTitleImageUrl(portfolio),
featuredMediaId: portfolio.featuredMediaId,
images: portfolio.gallery.map((item) => item.url),
galleryMediaIds: portfolio.galleryMediaIds,
}
}
export async function listPortfolios(
page = 1,
pageSize = PORTFOLIOS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListPortfoliosParams, '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?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<PortfoliosListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapPortfolioApiToUi),
}
}
export async function getPortfolio(portfolioId: string, signal?: AbortSignal) {
const data = await apiRequest<{ portfolio: PortfolioApi }>(businessPath(`/${portfolioId}`), {
auth: true,
signal,
})
return data.portfolio
}
export async function getPortfolioDetail(portfolioId: string, signal?: AbortSignal) {
const portfolio = await getPortfolio(portfolioId, signal)
return mapPortfolioDetailFromApi(portfolio)
}
export async function createPortfolio(payload: CreatePortfolioPayload) {
return apiRequest<{ message: string; portfolio: PortfolioApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updatePortfolio(portfolioId: string, payload: UpdatePortfolioPayload) {
return apiRequest<{ message: string; portfolio: PortfolioApi }>(
businessPath(`/${portfolioId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deletePortfolio(portfolioId: string) {
return apiRequest<{ message: string }>(businessPath(`/${portfolioId}`), {
method: 'DELETE',
auth: true,
})
}