import type { BlogArticleResponseDto, BlogArticleSummaryResponseDto, CityResponseDto, DiscoveryCategorySummaryDto, DiscoveryEventResponseDto, EventCategoryResponseDto, HomeFeedResponseDto, } from '@/api/generated/models' import { isSeoEligibleEvent } from '@/lib/seo/contentQuality' import type { EventFaq, EventMedia } from '@/services/eventDetail' import type { EventReview, OrganizerReview } from '@/services/reviews' /** * Server-only data fetching for public SEO pages (App Router server * components). Deliberately does NOT reuse `@/config/axios` — that * instance is wired for the browser (js-cookie, refresh-on-401 via * `window`) and isn't safe to import into a server component. These * pages are public/unauthenticated (`@Public()` backend routes), so a * bare `fetch` against `NEXT_PUBLIC_API_URL` is enough. * * Owned by saza2 (docs/prompt/saza2.md) as part of the Phase 3 SEO * foundation stub — see that file and `_shared.md` for why this exists * ahead of saza1's real foundation task. */ export interface SeoCity { id: number provinceId: number provinceName: string provinceSlug: string name: string slug: string lat: number lng: number } export type SeoCategory = EventCategoryResponseDto export type SeoDiscoveryEvent = DiscoveryEventResponseDto function resolveApiBaseUrl(): string { const raw = process.env.NEXT_PUBLIC_API_URL if (!raw) { throw new Error('NEXT_PUBLIC_API_URL is not configured') } const trimmed = raw.replace(/\/+$/, '') return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1` } type ApiEnvelope = { success: true; data: T } | { success: false; message: string; code?: string } /** Server-cache public discovery reference data for five days. */ const DISCOVERY_REFERENCE_REVALIDATE_SECONDS = 5 * 24 * 60 * 60 async function publicGet(path: string, params?: Record, revalidate: number | false = 300): Promise { const url = new URL(`${resolveApiBaseUrl()}/${path.replace(/^\/+/, '')}`) if (params) { for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)) } } const response = await fetch(url.toString(), { next: { revalidate } }) if (response.status === 404) return null if (!response.ok) { throw new Error(`SEO fetch failed: ${response.status} ${url.pathname}`) } const body = (await response.json()) as ApiEnvelope if (!body.success) { throw new Error(body.message || 'SEO fetch failed') } return body.data } /** `GET /geography/cities/:slug` — see backend geography.controller.ts */ export async function fetchPublicCityBySlug(slug: string): Promise { return publicGet(`geography/cities/${encodeURIComponent(slug)}`) } export type SeoCityListItem = CityResponseDto export type SeoDiscoveryCategory = DiscoveryCategorySummaryDto export interface DiscoveryBootstrap { categories: SeoCategory[] cities: SeoCityListItem[] } /** Cached city list for home/discovery (`GET /discovery/cities`) — refreshed every five days. */ export async function fetchDiscoveryCities(): Promise { const data = await publicGet('discovery/cities', undefined, DISCOVERY_REFERENCE_REVALIDATE_SECONDS) if (!data) throw new Error('Discovery cities fetch returned no data') return data } /** Cached category tree summary for home/discovery (`GET /discovery/categories`) — refreshed every five days. */ export async function fetchDiscoveryCategories(): Promise { const data = await publicGet('discovery/categories', undefined, DISCOVERY_REFERENCE_REVALIDATE_SECONDS) if (!data) throw new Error('Discovery categories fetch returned no data') return data } export type SeoHomeFeed = HomeFeedResponseDto /** Home sections payload (`GET /discovery/home-feed`). Revalidate faster than reference data. */ export async function fetchDiscoveryHomeFeed(cityId?: number): Promise { const params: Record = {} if (cityId != null) params.cityId = cityId const data = await publicGet('discovery/home-feed', params, 60) if (!data) throw new Error('Discovery home feed fetch returned no data') return data } /** @deprecated Prefer `fetchDiscoveryCities` + `fetchDiscoveryCategories`. */ export async function fetchDiscoveryBootstrap(): Promise { const data = await publicGet('discovery/bootstrap', undefined, false) if (!data) throw new Error('Discovery bootstrap fetch returned no data') return data } /** * `GET /geography/cities` (unpaginated flat list, `@Public()`). Used by * the `/city` hub. NOTE: `geography/provinces` isn't fetched here on * purpose — provinces don't carry a slug/dedicated landing page in this * stub, so the hub groups by the city row's own `provinceId` and shows * a numeric fallback if a province name lookup is added later. */ export async function fetchPublicCities(): Promise { const url = new URL(`${resolveApiBaseUrl()}/geography/cities`) const response = await fetch(url.toString(), { next: { revalidate: 300 } }) if (!response.ok) throw new Error(`SEO cities fetch failed: ${response.status}`) const body = (await response.json()) as { data?: unknown } const data = body.data return Array.isArray(data) ? (data as SeoCityListItem[]) : [] } export interface SeoProvince { id: number name: string } /** `GET /geography/provinces` (unpaginated, `@Public()`) — for grouping the city hub by province name. */ export async function fetchPublicProvinces(): Promise { const url = new URL(`${resolveApiBaseUrl()}/geography/provinces`) const response = await fetch(url.toString(), { next: { revalidate: 300 } }) if (!response.ok) throw new Error(`SEO provinces fetch failed: ${response.status}`) const body = (await response.json()) as { data?: unknown } const data = body.data return Array.isArray(data) ? (data as SeoProvince[]) : [] } /** `GET /event-categories/:slug` — pre-existing public endpoint */ export async function fetchPublicCategoryBySlug(slug: string): Promise { return publicGet(`event-categories/${encodeURIComponent(slug)}`) } /** `GET /event-categories` (flat, active-only, unpaginated, `@Public()`) — used by the `/category` hub. */ export async function fetchPublicCategories(): Promise { const url = new URL(`${resolveApiBaseUrl()}/event-categories`) const response = await fetch(url.toString(), { next: { revalidate: 300 } }) if (!response.ok) throw new Error(`SEO categories fetch failed: ${response.status}`) const body = (await response.json()) as { data?: unknown } const data = body.data return Array.isArray(data) ? (data as SeoCategory[]) : [] } export interface SeoEventDetail { id: string organizerId: string categoryId: number /** Present on `GET /events/:id` and related EventResponseDto payloads. */ categoryName?: string cityName?: string 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: string | number capacity: number bookedCount: number reservedCapacity?: number status: string isFeatured: boolean posterUrl: string | null avgRating?: number | null reviewsCount?: number genderRestriction?: string | null ageRestriction?: string | null settings?: { isDiscoverable: boolean addressVisibility: 'public' | 'attendees_only' generalArea: string | null } } export interface SeoEventLandingOrganizer { id: string firstName: string lastName: string avatarUrl: string | null gender: 'male' | 'female' | 'other' | null followersCount: number pastEventsCount: number contactLinks: { channel: string; label: string; url: string }[] } export interface SeoEventLandingBootstrap { event: SeoEventDetail category: { id: number; name: string; slug: string } city: { id: number; name: string } media: EventMedia[] faqs: EventFaq[] organizer: SeoEventLandingOrganizer reviews: EventReview[] reviewsTotal: number } /** One public/cacheable payload for every non-personalized landing section. */ export async function fetchPublicEventLandingBootstrap(slug: string): Promise { return publicGet(`events/by-slug/${encodeURIComponent(slug)}/bootstrap`, undefined, 60) } /** `GET /events/by-slug/:slug` — public, discoverable events only (backend/src/modules/events/events.controller.ts). */ export async function fetchPublicEventBySlug(slug: string): Promise { return publicGet(`events/by-slug/${encodeURIComponent(slug)}`) } export type LegacyEventRedirectLookup = Pick & { settings?: { isDiscoverable?: boolean } } export type LegacyEventDetail = SeoEventDetail & { settings: { isDiscoverable: boolean addressVisibility: 'public' | 'attendees_only' generalArea: string | null } } /** * Resolves a legacy UUID event URL before rendering. The response is used only * to choose a canonical redirect; no private event content is rendered from * this server-side lookup. */ export async function fetchLegacyEventRedirectById(id: string): Promise { return publicGet(`events/${encodeURIComponent(id)}`, undefined, 60) } /** Authenticated legacy detail read. It is deliberately excluded from every public/server cache. */ export async function fetchLegacyEventForViewer(id: string, accessToken: string): Promise { const url = new URL(`${resolveApiBaseUrl()}/events/${encodeURIComponent(id)}`) const response = await fetch(url.toString(), { cache: 'no-store', headers: { Authorization: `Bearer ${accessToken}` }, }) if (response.status === 404) return null if (!response.ok) throw new Error(`Event detail fetch failed: ${response.status}`) const body = (await response.json()) as ApiEnvelope if (!body.success) throw new Error(body.message || 'Event detail fetch failed') return body.data } export interface SeoEventFilters { cityId?: number categoryId?: number categoryIds?: number[] page?: number pageSize?: number } export interface PublicOrganizer { id: string firstName: string lastName: string avatarUrl: string | null gender: 'male' | 'female' | 'other' | null bio: string | null cityName: string | null defaultAddress: string | null followersCount: number isVerified: boolean memberSince: string pastEventsCount: number totalGuestsCount?: number avgRating?: number | null reviewsCount?: number contactLinks: { channel: string; label: string; url: string; value: string }[] events: { id: string slug: string title: string shortDescription: string | null posterUrl: string | null startsAt: string cityName: string isFree: boolean price: string | number isPast: boolean }[] } export async function fetchPublicOrganizer(id: string): Promise { return publicGet(`users/public/${encodeURIComponent(id)}`, undefined, 300) } export interface PublicOrganizerBootstrap { organizer: PublicOrganizer reviews: OrganizerReview[] reviewsTotal: number } /** One public/cacheable payload for the organizer profile and first review page. */ export async function fetchPublicOrganizerBootstrap(id: string): Promise { return publicGet(`users/public/${encodeURIComponent(id)}/bootstrap`, undefined, 300) } /** * `GET /events` (public discovery) filtered for SEO landing pages. * Mirrors `frontend/services/discovery.ts` `fetchDiscoveryEvents` * (client-side, axios-based) but safe to call from a server component. */ export interface SeoDiscoveryPage { items: SeoDiscoveryEvent[] totalItemsCount: number sourceItemsCount: number sourceTotalItemsCount: number } export async function fetchSeoDiscoveryEvents(filters: SeoEventFilters): Promise { const params: Record = { page: filters.page ?? 1, pageSize: filters.pageSize ?? 24, } if (filters.cityId !== undefined) params['filters[cityId]'] = filters.cityId if (filters.categoryIds?.length) params['filters[categoryId]'] = filters.categoryIds.join(',') else if (filters.categoryId !== undefined) params['filters[categoryId]'] = filters.categoryId const url = new URL(`${resolveApiBaseUrl()}/events`) for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)) } const response = await fetch(url.toString(), { next: { revalidate: 300 } }) if (!response.ok) { throw new Error(`SEO discovery fetch failed: ${response.status}`) } const body = (await response.json()) as { data?: { items?: unknown; response?: { totalItemsCount?: unknown } } } const data = body.data ?? {} const rawItems = Array.isArray(data.items) ? (data.items as SeoDiscoveryEvent[]) : [] const items = rawItems.filter(isSeoEligibleEvent) const reportedTotal = Number(data.response?.totalItemsCount ?? rawItems.length) const totalItemsCount = Math.max(0, reportedTotal - (rawItems.length - items.length)) return { items, totalItemsCount, sourceItemsCount: rawItems.length, sourceTotalItemsCount: reportedTotal, } } /** Fetches every public discovery page so sitemap generation never drops events after page one. */ export async function fetchAllSeoDiscoveryEvents(pageSize = 100): Promise { const firstPage = await fetchSeoDiscoveryEvents({ page: 1, pageSize }) const totalPages = Math.ceil(firstPage.sourceTotalItemsCount / pageSize) if (totalPages <= 1) return firstPage.items const remainingPages = await Promise.all( Array.from({ length: totalPages - 1 }, (_, index) => fetchSeoDiscoveryEvents({ page: index + 2, pageSize })) ) return [firstPage, ...remainingPages].flatMap((page) => page.items) } export type SeoArticle = BlogArticleResponseDto export type SeoArticleSummary = BlogArticleSummaryResponseDto export interface SeoArticleFilters { categorySlug?: string citySlug?: string eventCategorySlug?: string featured?: boolean } const BLOG_REVALIDATE_SECONDS = 60 /** * `GET /blog-articles` (published-only, unpaginated, `@Public()`) — replaces * the old static `frontend/content/articles.ts` import. Used by `/blog`, * `/blog/category/[slug]`, `/blog/city/[slug]` and `sitemap.ts`. */ export async function fetchPublishedArticles(filters: SeoArticleFilters = {}): Promise { const url = new URL(`${resolveApiBaseUrl()}/blog-articles`) if (filters.categorySlug) url.searchParams.set('categorySlug', filters.categorySlug) if (filters.citySlug) url.searchParams.set('citySlug', filters.citySlug) if (filters.eventCategorySlug) url.searchParams.set('eventCategorySlug', filters.eventCategorySlug) if (filters.featured !== undefined) url.searchParams.set('featured', String(filters.featured)) const response = await fetch(url.toString(), { next: { revalidate: BLOG_REVALIDATE_SECONDS } }) if (!response.ok) throw new Error(`SEO articles fetch failed: ${response.status}`) const body = (await response.json()) as { data?: unknown } const data = body.data return Array.isArray(data) ? (data as SeoArticleSummary[]) : [] } /** `GET /blog-articles/:slug` — published-only, `@Public()`. */ export async function fetchArticleBySlug(slug: string): Promise { return publicGet(`blog-articles/${encodeURIComponent(slug)}`, undefined, BLOG_REVALIDATE_SECONDS) } /** `GET /blog-articles/:slug/related` — lightweight card data only. */ export async function fetchRelatedArticles(slug: string, limit = 3): Promise { const result = await publicGet( `blog-articles/${encodeURIComponent(slug)}/related`, { limit }, BLOG_REVALIDATE_SECONDS ) return result ?? [] }