Step 8.4: add guest onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ba25066105
commit
6a6e20d30f
|
|
@ -382,3 +382,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Sync uses the midpoint of the round trip:** offset = server time − (sent + received) / 2.
|
||||
- **A one-second ticker** (`startClockTicker`) refreshes `now` in the store so components re-render; components read `useClock(s => s.now)`.
|
||||
- **The header shows in-game time** as `YYYY-MM-DD HH:MM` (UTC), from sim `realToGame`.
|
||||
|
||||
## Step 8.4
|
||||
|
||||
- **"Is there a session?" is decided by the server:** the client loads player state on start-up; `401 unauthorized` means no session, so it shows the start screen. The session itself is Better Auth's persistent cookie, which the browser keeps across reloads ("persist the session"); the client stores nothing. Expired sessions land on the start screen too.
|
||||
- **After "Start your company"** the guest response's state is written straight into the query cache and the app navigates to HQ, so there is no extra request.
|
||||
- **TanStack Query and an API context arrive here** rather than in Step 8.5, because onboarding needs the state query. The header shows live credits from it.
|
||||
- **Client test helpers** (`apps/web/test/render.tsx`): `fakeApi` (per-route handlers), `samplePlayerState`, and `renderApp` (fresh query cache, memory router).
|
||||
|
|
|
|||
|
|
@ -1,13 +1,48 @@
|
|||
import { useEffect } from "react";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { useApi } from "./api/context";
|
||||
import { startClockTicker, syncClock } from "./clock/clock";
|
||||
import { Layout } from "./layout/Layout";
|
||||
import { SCREENS } from "./routes";
|
||||
import { Placeholder } from "./screens/Placeholder";
|
||||
import { StartScreen } from "./screens/StartScreen";
|
||||
import { usePlayerState } from "./state/player";
|
||||
|
||||
/** Routes for every screen. The router itself is provided by the caller. */
|
||||
export function formatCredits(credits: number): string {
|
||||
return `${credits.toLocaleString("en-US")} cr`;
|
||||
}
|
||||
|
||||
/** Syncs the clock with the server once, then ticks it every second. */
|
||||
function useServerClock() {
|
||||
const api = useApi();
|
||||
useEffect(() => {
|
||||
syncClock(api).catch(() => {
|
||||
// Offline or server down: keep the device clock until the next launch.
|
||||
});
|
||||
return startClockTicker();
|
||||
}, [api]);
|
||||
}
|
||||
|
||||
/** The whole client. Router, API and query providers come from the caller. */
|
||||
export function App() {
|
||||
useServerClock();
|
||||
const state = usePlayerState();
|
||||
|
||||
if (state.isPending) {
|
||||
return <p className="p-6 text-slate-400">Loading…</p>;
|
||||
}
|
||||
if (state.isError) {
|
||||
return (
|
||||
<p role="alert" className="p-6 text-red-400">
|
||||
Could not reach the server.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.data === null) return <StartScreen />;
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
||||
{SCREENS.map((screen) => (
|
||||
<Route
|
||||
key={screen.path}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ApiClient } from "./client";
|
||||
|
||||
const ApiContext = createContext<ApiClient | null>(null);
|
||||
|
||||
export function ApiProvider({ api, children }: { api: ApiClient; children: ReactNode }) {
|
||||
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
|
||||
}
|
||||
|
||||
/** The API client; tests provide a fake one. */
|
||||
export function useApi(): ApiClient {
|
||||
const api = useContext(ApiContext);
|
||||
if (!api) throw new Error("useApi needs an <ApiProvider>");
|
||||
return api;
|
||||
}
|
||||
|
|
@ -1,13 +1,23 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { App } from "./App";
|
||||
import { createApiClient } from "./api/client";
|
||||
import { ApiProvider } from "./api/context";
|
||||
import "./index.css";
|
||||
|
||||
const api = createApiClient();
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider api={api}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ApiProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { PLAYER_STATE_KEY } from "../state/player";
|
||||
|
||||
/** First launch: create a guest account (decision: guests first, accounts later). */
|
||||
export function StartScreen() {
|
||||
const api = useApi();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.call("createGuest", {}),
|
||||
onSuccess: ({ state }) => {
|
||||
// The session cookie is now set; the response already holds the new state.
|
||||
queryClient.setQueryData(PLAYER_STATE_KEY, state);
|
||||
void navigate("/");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-slate-950 p-6 text-center text-slate-100">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-wide">DEADHEAD</h1>
|
||||
<p className="mt-1 text-slate-400">Space Freight Tycoon</p>
|
||||
</div>
|
||||
<p className="max-w-xs text-sm text-slate-300">
|
||||
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
||||
empire across the solar system.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending}
|
||||
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-950 disabled:opacity-60"
|
||||
>
|
||||
{start.isPending ? "Starting…" : "Start your company"}
|
||||
</button>
|
||||
{start.isError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
Could not start a company. Check your connection and try again.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, type ApiClient } from "../api/client";
|
||||
import { useApi } from "../api/context";
|
||||
|
||||
export const PLAYER_STATE_KEY = ["player-state"] as const;
|
||||
|
||||
/** Player state, or null when there is no session (the server says 401). */
|
||||
export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | null> {
|
||||
try {
|
||||
return await api.call("getPlayerState", {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === "unauthorized") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function usePlayerState() {
|
||||
const api = useApi();
|
||||
return useQuery({ queryKey: PLAYER_STATE_KEY, queryFn: () => fetchPlayerState(api) });
|
||||
}
|
||||
|
|
@ -1,36 +1,34 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { App } from "../src/App";
|
||||
import { SCREENS } from "../src/routes";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
const signedIn = () =>
|
||||
fakeApi({
|
||||
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }),
|
||||
});
|
||||
|
||||
describe("app layout", () => {
|
||||
it("renders the header with credits and game clock, and the bottom navigation", () => {
|
||||
renderAt("/");
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Credits")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Game clock")).toBeInTheDocument();
|
||||
it("renders the header with credits and game clock, and the bottom navigation", async () => {
|
||||
renderApp(signedIn());
|
||||
expect(await screen.findByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
|
||||
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
|
||||
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
|
||||
);
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
||||
});
|
||||
|
||||
it.each(SCREENS)("shows the $title placeholder at $path", ({ path, title }) => {
|
||||
renderAt(path);
|
||||
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||
it.each(SCREENS)("shows the $title placeholder at $path", async ({ path, title }) => {
|
||||
renderApp(signedIn(), path);
|
||||
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates between screens with the bottom navigation", async () => {
|
||||
renderAt("/");
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
renderApp(signedIn());
|
||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
|
||||
expect(within(nav).getByRole("link", { name: "Fleet" })).toHaveAttribute(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "../src/api/client";
|
||||
import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
|
||||
|
||||
describe("guest onboarding", () => {
|
||||
it("shows the start screen without a session; the button creates a guest and opens HQ", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: unauthorized,
|
||||
createGuest: () => ({ playerId: "p1", state: samplePlayerState() }),
|
||||
});
|
||||
renderApp(api, "/fleet");
|
||||
|
||||
const start = await screen.findByRole("button", { name: "Start your company" });
|
||||
expect(screen.queryByRole("navigation", { name: "Main" })).not.toBeInTheDocument();
|
||||
await userEvent.click(start);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
|
||||
expect(api.call).toHaveBeenCalledWith("createGuest", {});
|
||||
});
|
||||
|
||||
it("skips the start screen when a stored session is still valid", async () => {
|
||||
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
||||
renderApp(api);
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
|
||||
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
|
||||
});
|
||||
|
||||
it("shows an error and stays on the start screen if creating the guest fails", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: unauthorized,
|
||||
createGuest: () => {
|
||||
throw new ApiError(500, "internal-error", "boom");
|
||||
},
|
||||
});
|
||||
renderApp(api);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Start your company" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not start a company");
|
||||
expect(screen.getByRole("button", { name: "Start your company" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { vi } from "vitest";
|
||||
import { App } from "../src/App";
|
||||
import { ApiError, type ApiClient } from "../src/api/client";
|
||||
import { ApiProvider } from "../src/api/context";
|
||||
|
||||
type Handlers = {
|
||||
[N in RouteName]?: (input: RouteRequest<N>) => RouteResponse<N> | Promise<RouteResponse<N>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A fake API: each route answers from its handler. Unhandled routes fail with
|
||||
* `not-found`; `getServerTime` defaults to the device clock.
|
||||
*/
|
||||
export function fakeApi(handlers: Handlers) {
|
||||
const call = vi.fn(async (name: RouteName, input: unknown) => {
|
||||
if (name === "getServerTime" && !handlers.getServerTime) {
|
||||
return { realTime: Date.now(), gameTime: 0 };
|
||||
}
|
||||
const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
|
||||
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
|
||||
return handler(input);
|
||||
});
|
||||
return { call } as unknown as ApiClient & { call: typeof call };
|
||||
}
|
||||
|
||||
export const unauthorized = () => {
|
||||
throw new ApiError(401, "unauthorized", "Sign in to continue.");
|
||||
};
|
||||
|
||||
/** A brand-new tutorial player, as guest sign-up returns it. */
|
||||
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
return {
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 0 },
|
||||
ships: [],
|
||||
pilots: [
|
||||
{
|
||||
id: "avatar",
|
||||
name: "Captain",
|
||||
isAvatar: true,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
skillPointsAvailable: 0,
|
||||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
},
|
||||
],
|
||||
bays: [],
|
||||
locations: [
|
||||
{ id: "earth", discovered: true },
|
||||
{ id: "moon", discovered: true },
|
||||
{ id: "mars", discovered: false },
|
||||
{ id: "ceres-station", discovered: false },
|
||||
{ id: "jupiter-cloud-platform", discovered: false },
|
||||
],
|
||||
tutorial: { loanerLocationId: "earth", runInFlight: false },
|
||||
activeDispatches: [],
|
||||
summaries: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Renders the whole app at `path` with a fresh query cache and the given API. */
|
||||
export function renderApp(api: ApiClient, path = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider api={api}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</ApiProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...result, queryClient };
|
||||
}
|
||||
|
|
@ -80,5 +80,5 @@
|
|||
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
|
||||
- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message.
|
||||
- [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time.
|
||||
- [ ] Step 8.4 — Guest onboarding
|
||||
- [x] **Step 8.4 — Guest onboarding.** No session (state request returns 401) shows `StartScreen`; "Start your company" calls `createGuest`, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and an `ApiProvider` context added; header shows live credits. Verified end to end through the Vite proxy.
|
||||
- [ ] Step 8.5 — Player state store
|
||||
|
|
|
|||
Loading…
Reference in New Issue