gendesign/tradein-mvp/frontend/src/lib/api.ts
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

92 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getOrCreateSessionId } from "@/lib/sessionId";
// Empty default = same-origin relative URLs.
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
/**
* Merges a global X-Session-Id header into the request init.
* Only runs in browser (SSR guard). User-supplied headers win over the
* auto-injected value so explicit overrides remain backward-compatible.
*/
function withSessionHeader(init?: RequestInit): RequestInit {
const base: RequestInit = init ?? {};
if (typeof window === "undefined") return base;
const sessionId = getOrCreateSessionId();
if (!sessionId) return base;
return {
...base,
headers: {
"X-Session-Id": sessionId,
...(base.headers ?? {}),
},
};
}
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
}
/**
* Status-aware variant of apiFetch — возвращает status + body для случаев
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
*
* Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК.
*/
export class HTTPError extends Error {
status: number;
body: unknown;
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `API error ${status}`);
this.status = status;
this.body = body;
}
}
export async function apiFetchWithStatus<T>(
path: string,
init?: RequestInit,
): Promise<{ status: number; body: T }> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
let body: unknown = null;
// 204 No Content имеет пустое тело; для всех остальных читаем как text один раз
// (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()).
if (response.status !== 204) {
const rawText = await response.text();
if (rawText) {
try {
body = JSON.parse(rawText);
} catch {
// Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail
body = { detail: rawText };
}
}
}
if (!response.ok) {
const detail =
(body as { detail?: string })?.detail ?? `API error ${response.status}`;
throw new HTTPError(response.status, body, detail);
}
return { status: response.status, body: body as T };
}