Files
germanandClaude Opus 4.8 ad35d4e992
Deploy / deploy (push) Failing after 5s
feat(#3): frontend scaffold (Next.js 14 App Router, TS, Tailwind, галерея)
Каркас витрины дизайнера Ники: главная (hero + сетка галереи),
страница работы (детали + место под аукцион), список аукционов,
подписка-заглушка. Клиент к backend /api с graceful-фолбэком на
демо-работы при недоступном API. Standalone-сборка + Dockerfile.
Сборка npm run build и tsc --noEmit проходят.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 19:42:13 +03:00

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 };
}
}