Deploy / deploy (push) Failing after 5s
Каркас витрины дизайнера Ники: главная (hero + сетка галереи), страница работы (детали + место под аукцион), список аукционов, подписка-заглушка. Клиент к backend /api с graceful-фолбэком на демо-работы при недоступном API. Standalone-сборка + Dockerfile. Сборка npm run build и tsc --noEmit проходят. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
// Клиент к backend-API (Express, порт 4000, публикуется nginx под /api).
|
|
// База берётся из NEXT_PUBLIC_API_URL (например https://nika-gallery.ru/api).
|
|
// В dev по умолчанию — http://localhost:4000/api.
|
|
|
|
import type { Auction, Bid, Paginated, Work } from './types';
|
|
|
|
export const API_BASE = (
|
|
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'
|
|
).replace(/\/$/, '');
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(message: string, status: number) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
type FetchOpts = RequestInit & { revalidate?: number };
|
|
|
|
async function request<T>(path: string, opts: FetchOpts = {}): Promise<T> {
|
|
const { revalidate, ...init } = opts;
|
|
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
|
const res = await fetch(url, {
|
|
...init,
|
|
headers: { Accept: 'application/json', ...(init.headers || {}) },
|
|
// ISR: обновляем витрину периодически; переопределяемо на месте вызова
|
|
next: revalidate !== undefined ? { revalidate } : { revalidate: 60 },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let msg = `${res.status} ${res.statusText}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error || body?.message) msg = body.error || body.message;
|
|
} catch {
|
|
/* тело не JSON — оставляем статус */
|
|
}
|
|
throw new ApiError(msg, res.status);
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
// Нормализуем список: бэкенд может вернуть массив или {items,total}.
|
|
function toArray<T>(data: T[] | Paginated<T>): T[] {
|
|
return Array.isArray(data) ? data : data.items ?? [];
|
|
}
|
|
|
|
export const api = {
|
|
// ── Работы ─────────────────────────────────────────────
|
|
async listWorks(params?: {
|
|
status?: string;
|
|
category?: string;
|
|
limit?: number;
|
|
}): Promise<Work[]> {
|
|
const q = new URLSearchParams();
|
|
if (params?.status) q.set('status', params.status);
|
|
if (params?.category) q.set('category', params.category);
|
|
if (params?.limit) q.set('limit', String(params.limit));
|
|
const qs = q.toString() ? `?${q}` : '';
|
|
return toArray(await request<Work[] | Paginated<Work>>(`/works${qs}`));
|
|
},
|
|
|
|
async getWork(idOrSlug: string): Promise<Work> {
|
|
return request<Work>(`/works/${encodeURIComponent(idOrSlug)}`);
|
|
},
|
|
|
|
// ── Аукционы ───────────────────────────────────────────
|
|
async listAuctions(params?: { status?: string }): Promise<Auction[]> {
|
|
const q = new URLSearchParams();
|
|
if (params?.status) q.set('status', params.status);
|
|
const qs = q.toString() ? `?${q}` : '';
|
|
return toArray(
|
|
await request<Auction[] | Paginated<Auction>>(`/auctions${qs}`),
|
|
);
|
|
},
|
|
|
|
async getAuction(id: string): Promise<Auction> {
|
|
return request<Auction>(`/auctions/${encodeURIComponent(id)}`);
|
|
},
|
|
|
|
async getAuctionBids(id: string): Promise<Bid[]> {
|
|
return toArray(
|
|
await request<Bid[] | Paginated<Bid>>(
|
|
`/auctions/${encodeURIComponent(id)}/bids`,
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
// Безопасная обёртка для серверных компонентов: при недоступном backend
|
|
// возвращаем fallback вместо падения страницы (graceful degradation).
|
|
export async function safe<T>(
|
|
fn: () => Promise<T>,
|
|
fallback: T,
|
|
): Promise<{ data: T; ok: boolean }> {
|
|
try {
|
|
return { data: await fn(), ok: true };
|
|
} catch {
|
|
return { data: fallback, ok: false };
|
|
}
|
|
}
|