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.
|
- **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)`.
|
- **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`.
|
- **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 { Route, Routes } from "react-router";
|
||||||
|
import { useApi } from "./api/context";
|
||||||
|
import { startClockTicker, syncClock } from "./clock/clock";
|
||||||
import { Layout } from "./layout/Layout";
|
import { Layout } from "./layout/Layout";
|
||||||
import { SCREENS } from "./routes";
|
import { SCREENS } from "./routes";
|
||||||
import { Placeholder } from "./screens/Placeholder";
|
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() {
|
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 (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
||||||
{SCREENS.map((screen) => (
|
{SCREENS.map((screen) => (
|
||||||
<Route
|
<Route
|
||||||
key={screen.path}
|
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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { createApiClient } from "./api/client";
|
||||||
|
import { ApiProvider } from "./api/context";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
const api = createApiClient();
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ApiProvider api={api}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</ApiProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { App } from "../src/App";
|
|
||||||
import { SCREENS } from "../src/routes";
|
import { SCREENS } from "../src/routes";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
function renderAt(path: string) {
|
const signedIn = () =>
|
||||||
return render(
|
fakeApi({
|
||||||
<MemoryRouter initialEntries={[path]}>
|
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }),
|
||||||
<App />
|
});
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("app layout", () => {
|
describe("app layout", () => {
|
||||||
it("renders the header with credits and game clock, and the bottom navigation", () => {
|
it("renders the header with credits and game clock, and the bottom navigation", async () => {
|
||||||
renderAt("/");
|
renderApp(signedIn());
|
||||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
expect(await screen.findByRole("banner")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Credits")).toBeInTheDocument();
|
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
|
||||||
expect(screen.getByLabelText("Game clock")).toBeInTheDocument();
|
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
|
||||||
|
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
|
||||||
|
);
|
||||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||||
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(SCREENS)("shows the $title placeholder at $path", ({ path, title }) => {
|
it.each(SCREENS)("shows the $title placeholder at $path", async ({ path, title }) => {
|
||||||
renderAt(path);
|
renderApp(signedIn(), path);
|
||||||
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates between screens with the bottom navigation", async () => {
|
it("navigates between screens with the bottom navigation", async () => {
|
||||||
renderAt("/");
|
renderApp(signedIn());
|
||||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||||
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
|
||||||
expect(within(nav).getByRole("link", { name: "Fleet" })).toHaveAttribute(
|
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.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.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.
|
- [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
|
- [ ] Step 8.5 — Player state store
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue