admin/services/discovery.ts

231 lines
7.1 KiB
TypeScript

import type {
DiscoveryCategorySummaryDto,
HomeCategoryPreviewEventDto,
HomeFeedResponseDto,
HomePopularEventDto,
} from '@/api/generated/models'
import { getEvents } from '@/api/generated/events/events'
import { DISCOVERY_PAGE_SIZE } from '@/constants/discovery'
import axiosInstance from '@/config/axios'
import type { City } from '@/services/geography'
import {
errorResult,
handleServiceError,
type ServiceCallOptions,
type ServiceResult,
shouldBubbleErrorToParent,
successResult,
} from '@/services/errorHandler'
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
import { unwrapApiPayload } from '@/helpers/listResponse'
import type { EventStatus } from '@/types/status'
/** Lightweight host name on public discovery cards. */
export interface DiscoveryEventOrganizer {
firstName: string | null
lastName: string | null
}
/**
* Public discovery event shape (GET /events).
* Hand-written so type-aware ESLint resolves it while `api/generated` stays eslint-ignored.
*/
export interface DiscoveryEvent {
id: string
organizerId: string
categoryId: number
clonedFromEventId: string | null
title: string
slug: string
shortDescription: string | null
description: string | null
startsAt: string
endsAt: string
provinceId: number
cityId: number
address: string | null
lat: number | null
lng: number | null
isFree: boolean
price: number
capacity: number
bookedCount: number
reservedCapacity: number
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
commissionPercent: number | null
effectiveCommissionPercent: number
status: EventStatus
isFeatured: boolean
publishedAt: string | null
adminApprovedAt: string | null
rejectionReason: string | null
avgRating?: number | null
reviewsCount: number
createdAt: string
updatedAt: string
posterUrl: string | null
squarePosterUrl: string | null
organizer: DiscoveryEventOrganizer
}
export type DiscoveryCategory = DiscoveryCategorySummaryDto
export type HomePopularEvent = HomePopularEventDto
export type HomeCategoryPreviewEvent = HomeCategoryPreviewEventDto
export type HomeFeed = HomeFeedResponseDto
export interface DiscoveryFilters {
categoryIds?: number[]
cityId?: number
provinceId?: number
startsAtFrom?: string
startsAtTo?: string
}
export interface DiscoveryListResult {
items: DiscoveryEvent[]
totalItemsCount: number
}
export interface DiscoveryPagination {
page?: number
pageSize?: number
sort?: string
}
export interface HomeFeedQuery {
cityId?: number
}
export interface DiscoveryBootstrap {
categories: DiscoveryCategory[]
cities: City[]
}
const eventsApi = getEvents()
const eventCategoriesApi = getEventCategories()
// Cities and categories are shared public reference data, but are still
// admin-managed. A finite window lets long-lived tabs see taxonomy changes
// after focus/reconnect instead of keeping them stale until a deploy.
const REFERENCE_STALE_MS = 5 * 60_000
const HOME_FEED_STALE_MS = 60_000
export const discoveryReferenceStaleTime = REFERENCE_STALE_MS
export const discoveryHomeFeedStaleTime = HOME_FEED_STALE_MS
export function homeCategoryPreviewMap(feed: HomeFeed | undefined): Map<number, HomeCategoryPreviewEvent[]> {
const map = new Map<number, HomeCategoryPreviewEvent[]>()
for (const group of feed?.categoryPreviews ?? []) {
map.set(group.categoryId, group.items)
}
return map
}
export async function fetchDiscoveryCities(): Promise<City[]> {
const response = await eventsApi.discoveryBootstrapControllerListCities()
const payload = unwrapApiPayload<City[]>(response.data)
return payload ?? []
}
export async function fetchDiscoveryCategories(): Promise<DiscoveryCategory[]> {
const response = await eventsApi.discoveryBootstrapControllerListCategories()
const payload = unwrapApiPayload<DiscoveryCategory[]>(response.data)
return payload ?? []
}
export async function fetchHomeFeed(query: HomeFeedQuery = {}, options?: ServiceCallOptions): Promise<ServiceResult<HomeFeed>> {
try {
const response = await eventsApi.discoveryBootstrapControllerGetHomeFeed({
cityId: query.cityId,
})
const payload = unwrapApiPayload<HomeFeed>(response.data)
if (!payload) {
throw new Error('Home feed fetch returned no data')
}
return successResult(payload)
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
/** @deprecated Prefer `fetchDiscoveryCities` + `fetchDiscoveryCategories`. */
export async function fetchDiscoveryBootstrap(): Promise<DiscoveryBootstrap> {
const response = await axiosInstance.get('discovery/bootstrap')
const payload = unwrapApiPayload<DiscoveryBootstrap>(response.data)
return payload ?? { categories: [], cities: [] }
}
/** Full category list via event-categories module (admin-adjacent surfaces). */
export async function fetchEventCategoriesList(options?: ServiceCallOptions): Promise<ServiceResult<DiscoveryCategory[]>> {
try {
const response = await eventCategoriesApi.eventCategoriesControllerList()
const payload = unwrapApiPayload(response.data)
return successResult(Array.isArray(payload) ? (payload as DiscoveryCategory[]) : [])
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export async function fetchDiscoveryEvents(
filters: DiscoveryFilters,
paginationOptions: DiscoveryPagination = {},
options?: ServiceCallOptions
): Promise<ServiceResult<DiscoveryListResult>> {
try {
const params: {
page: number
pageSize: number
sort?: string
filters: Record<string, string>
} = {
page: paginationOptions.page ?? 1,
pageSize: paginationOptions.pageSize ?? DISCOVERY_PAGE_SIZE,
filters: {},
}
if (paginationOptions.sort) params.sort = paginationOptions.sort
if (filters.categoryIds?.length) params.filters.categoryId = filters.categoryIds.join(',')
if (filters.cityId !== undefined) params.filters.cityId = String(filters.cityId)
if (filters.provinceId !== undefined) params.filters.provinceId = String(filters.provinceId)
if (filters.startsAtFrom) params.filters.startsAtFrom = filters.startsAtFrom
if (filters.startsAtTo) params.filters.startsAtTo = filters.startsAtTo
const response = await eventsApi.eventsControllerDiscovery(params)
const body = response.data as { data?: { items?: DiscoveryEvent[]; response?: { totalItemsCount?: number } } }
const data = body.data ?? {}
const items = Array.isArray(data.items) ? data.items : []
const totalItemsCount = data.response?.totalItemsCount ?? items.length
return successResult({ items, totalItemsCount })
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}