Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
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<T> = { 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<T>(path: string, params?: Record<string, string | number>, revalidate: number | false = 300): Promise<T | null> {
|
|
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<T>
|
|
|
|
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<SeoCity | null> {
|
|
return publicGet<SeoCity>(`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<SeoCityListItem[]> {
|
|
const data = await publicGet<SeoCityListItem[]>('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<SeoDiscoveryCategory[]> {
|
|
const data = await publicGet<SeoDiscoveryCategory[]>('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<SeoHomeFeed> {
|
|
const params: Record<string, string | number> = {}
|
|
|
|
if (cityId != null) params.cityId = cityId
|
|
|
|
const data = await publicGet<SeoHomeFeed>('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<DiscoveryBootstrap> {
|
|
const data = await publicGet<DiscoveryBootstrap>('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<SeoCityListItem[]> {
|
|
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<SeoProvince[]> {
|
|
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<SeoCategory | null> {
|
|
return publicGet<SeoCategory>(`event-categories/${encodeURIComponent(slug)}`)
|
|
}
|
|
|
|
/** `GET /event-categories` (flat, active-only, unpaginated, `@Public()`) — used by the `/category` hub. */
|
|
export async function fetchPublicCategories(): Promise<SeoCategory[]> {
|
|
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<SeoEventLandingBootstrap | null> {
|
|
return publicGet<SeoEventLandingBootstrap>(`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<SeoEventDetail | null> {
|
|
return publicGet<SeoEventDetail>(`events/by-slug/${encodeURIComponent(slug)}`)
|
|
}
|
|
|
|
export type LegacyEventRedirectLookup = Pick<SeoEventDetail, 'id' | 'slug' | 'status'> & {
|
|
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<LegacyEventDetail | null> {
|
|
return publicGet<LegacyEventDetail>(`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<LegacyEventDetail | null> {
|
|
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<LegacyEventDetail>
|
|
|
|
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<PublicOrganizer | null> {
|
|
return publicGet<PublicOrganizer>(`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<PublicOrganizerBootstrap | null> {
|
|
return publicGet<PublicOrganizerBootstrap>(`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<SeoDiscoveryPage> {
|
|
const params: Record<string, string | number> = {
|
|
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<SeoDiscoveryEvent[]> {
|
|
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<SeoArticleSummary[]> {
|
|
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<SeoArticle | null> {
|
|
return publicGet<SeoArticle>(`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<SeoArticleSummary[]> {
|
|
const result = await publicGet<SeoArticleSummary[]>(
|
|
`blog-articles/${encodeURIComponent(slug)}/related`,
|
|
{ limit },
|
|
BLOG_REVALIDATE_SECONDS
|
|
)
|
|
|
|
return result ?? []
|
|
}
|