Phase 2: API client, typed hooks, live probe
- Add api:types script (openapi-typescript against live FastAPI) - Generate src/lib/api/types.ts (2972 lines, 55 paths, 16 schemas) - lib/api/client.ts: typed apiRequest + ApiError + makeQueryClient (staleTime 5s, no refetchOnWindowFocus to preserve editor state) - lib/providers.tsx: QueryClientProvider client component, useState singleton so App Router re-renders don't dump the cache - lib/api/cases.ts: Case type + casesKeys + useCases hook (pragmatic hand-typed Case pending backend response-model annotations) - layout.tsx: wrap children with <Providers> - Smoke test: CasesLiveProbe component on home page hitting live FastAPI via /api/cases rewrite proxy Phase 2 deliverable check: useCases() returns typed Case[] from the production FastAPI through the Next.js proxy. End-to-end wiring proven. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Heebo } from "next/font/google";
|
||||
import { Providers } from "@/lib/providers";
|
||||
import "./globals.css";
|
||||
|
||||
const heebo = Heebo({
|
||||
@@ -21,7 +22,9 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="he" dir="rtl" className={`${heebo.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { CasesLiveProbe } from "@/components/cases/cases-live-probe";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
@@ -15,11 +16,8 @@ export default function HomePage() {
|
||||
<hr className="border-0 h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<div className="rounded-lg bg-surface shadow-sm p-6 border border-rule">
|
||||
<p className="text-ink">
|
||||
שלב 1 של תוכנית השדרוג הושלם: scaffold של Next.js 16, פורט של design
|
||||
tokens ל-Tailwind v4, RTL עברית עם Heebo. הפאזות הבאות יביאו את 10
|
||||
המסכים ל-parity עם ה-UI הקיים.
|
||||
</p>
|
||||
<h2 className="text-navy text-xl mb-3">פאזה 2 — חיבור חי ל-API</h2>
|
||||
<CasesLiveProbe />
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
51
web-ui/src/components/cases/cases-live-probe.tsx
Normal file
51
web-ui/src/components/cases/cases-live-probe.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCases } from "@/lib/api/cases";
|
||||
|
||||
/**
|
||||
* Phase 2 smoke-test component — proves that the typed API client, the
|
||||
* TanStack Query provider, the Next.js rewrite proxy, and the FastAPI backend
|
||||
* are all wired up correctly end-to-end. Temporary; replaced in Phase 3 by the
|
||||
* real case list view.
|
||||
*/
|
||||
export function CasesLiveProbe() {
|
||||
const { data, isLoading, isError, error } = useCases();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-ink-muted">טוען תיקים מה-API…</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-danger">
|
||||
<p className="font-medium">שגיאה בטעינת תיקים</p>
|
||||
<p className="text-sm text-ink-muted">
|
||||
{error instanceof Error ? error.message : "שגיאה לא ידועה"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const count = data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink">
|
||||
<span className="font-semibold text-navy">{count}</span>{" "}
|
||||
תיקים טעונים מה-API בהצלחה.
|
||||
</p>
|
||||
{data && data.length > 0 && (
|
||||
<ul className="text-sm text-ink-muted space-y-1">
|
||||
{data.slice(0, 3).map((c) => (
|
||||
<li key={c.case_number}>
|
||||
<span className="text-gold-deep">{c.case_number}</span> — {c.title}
|
||||
</li>
|
||||
))}
|
||||
{data.length > 3 && <li>ועוד {data.length - 3}…</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
web-ui/src/lib/api/cases.ts
Normal file
54
web-ui/src/lib/api/cases.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Cases domain hooks.
|
||||
*
|
||||
* Note on types: the FastAPI `/api/cases` endpoint doesn't declare a response
|
||||
* model, so openapi-typescript emits `unknown` for its payload. Until the
|
||||
* backend is annotated (see out-of-scope in the rewrite plan), we maintain a
|
||||
* small local type that matches what the running API returns today. Any drift
|
||||
* surfaces as a runtime TypeScript error the first time a property is touched.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type CaseStatus =
|
||||
| "new"
|
||||
| "uploading"
|
||||
| "processing"
|
||||
| "documents_ready"
|
||||
| "outcome_set"
|
||||
| "brainstorming"
|
||||
| "direction_approved"
|
||||
| "drafting"
|
||||
| "qa_review"
|
||||
| "drafted"
|
||||
| "exported"
|
||||
| "reviewed"
|
||||
| "final";
|
||||
|
||||
export type Case = {
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: CaseStatus;
|
||||
subject?: string | null;
|
||||
expected_outcome?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export const casesKeys = {
|
||||
all: ["cases"] as const,
|
||||
list: (detail: boolean) => [...casesKeys.all, "list", { detail }] as const,
|
||||
detail: (caseNumber: string) =>
|
||||
[...casesKeys.all, "detail", caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useCases(detail = false) {
|
||||
return useQuery({
|
||||
queryKey: casesKeys.list(detail),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<Case[]>(`/api/cases${detail ? "?detail=true" : ""}`, {
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
77
web-ui/src/lib/api/client.ts
Normal file
77
web-ui/src/lib/api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* API client — typed fetch wrapper + TanStack Query setup.
|
||||
*
|
||||
* All requests hit relative URLs (e.g. `/api/cases`) which next.config.ts
|
||||
* rewrites transparently proxy to legal-ai.nautilus.marcusgroup.org. No CORS
|
||||
* gymnastics, no direct public-URL references in component code.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly body: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed JSON request. Throws ApiError on non-2xx responses with the parsed body.
|
||||
* Always returns parsed JSON — callers pass the expected type parameter.
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
{ method = "GET", body, signal }: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const parsed = contentType.includes("application/json")
|
||||
? await res.json().catch(() => null)
|
||||
: await res.text().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
`${method} ${path} failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared TanStack Query client. Defaults are tuned for a dashboard that polls
|
||||
* for case progress — stale for 5 seconds, 1 automatic retry, no background
|
||||
* refetch on window focus (preserves editor state during long sessions).
|
||||
*/
|
||||
export function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
2972
web-ui/src/lib/api/types.ts
Normal file
2972
web-ui/src/lib/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
17
web-ui/src/lib/providers.tsx
Normal file
17
web-ui/src/lib/providers.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { makeQueryClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* Client-side providers. Creates ONE QueryClient instance per browser session
|
||||
* via useState — Next.js App Router recreates the function on every render,
|
||||
* so a naive `const client = makeQueryClient()` would dump the cache each time.
|
||||
*/
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(() => makeQueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user