Stay on the current list page after move-to-top; only toast success. Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
6.7 KiB
TypeScript
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,
|
|
})
|
|
}
|