@nano133/nanoterra 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -7
  3. package/ai/SPEC.md +2 -1
  4. package/dist/admin/panel/dom.d.ts +11 -0
  5. package/dist/admin/panel/dom.js +33 -0
  6. package/dist/admin/panel/gameCard.d.ts +5 -2
  7. package/dist/admin/panel/gameCard.js +8 -4
  8. package/dist/admin/panel/icons.d.ts +121 -0
  9. package/dist/admin/panel/icons.js +41 -0
  10. package/dist/admin/panel/index.d.ts +11 -2
  11. package/dist/admin/panel/index.js +6 -1
  12. package/dist/admin/panel/mount.js +101 -67
  13. package/dist/admin/panel/orders.d.ts +23 -0
  14. package/dist/admin/panel/orders.js +130 -0
  15. package/dist/admin/panel/overview.d.ts +22 -0
  16. package/dist/admin/panel/overview.js +75 -0
  17. package/dist/admin/panel/players.d.ts +7 -0
  18. package/dist/admin/panel/players.js +61 -0
  19. package/dist/admin/panel/shelf.d.ts +15 -3
  20. package/dist/admin/panel/shelf.js +385 -84
  21. package/dist/admin/panel/shelfData.js +1 -1
  22. package/dist/admin/panel/tabs.d.ts +23 -0
  23. package/dist/admin/panel/tabs.js +68 -0
  24. package/dist/looks/change.d.ts +5 -5
  25. package/dist/looks/change.js +12 -7
  26. package/dist/looks/index.d.ts +2 -2
  27. package/dist/looks/index.js +1 -1
  28. package/dist/looks/json.d.ts +3 -2
  29. package/dist/looks/json.js +16 -9
  30. package/dist/looks/look.d.ts +26 -6
  31. package/dist/looks/look.js +37 -12
  32. package/package.json +1 -1
  33. package/src/admin/panel/dom.ts +34 -0
  34. package/src/admin/panel/gameCard.ts +8 -4
  35. package/src/admin/panel/icons.ts +46 -0
  36. package/src/admin/panel/index.ts +11 -2
  37. package/src/admin/panel/mount.ts +120 -101
  38. package/src/admin/panel/orders.ts +172 -0
  39. package/src/admin/panel/overview.ts +134 -0
  40. package/src/admin/panel/players.ts +79 -0
  41. package/src/admin/panel/shelf.ts +549 -121
  42. package/src/admin/panel/shelfData.ts +1 -1
  43. package/src/admin/panel/style.css +1775 -270
  44. package/src/admin/panel/tabs.ts +94 -0
  45. package/src/looks/change.ts +12 -9
  46. package/src/looks/index.ts +2 -2
  47. package/src/looks/json.ts +12 -7
  48. package/src/looks/look.ts +47 -9
@@ -1,14 +1,23 @@
1
1
  // The admin panel: orders, owned looks, totals and the two game switches, from the game's own database. Only the game's owner can read them (the
2
2
  // handler checks it on every call); this page only shows them. `mountAdmin(element, options)` draws the panel into `element`.
3
+ //
4
+ // The page: a top bar (who is signed in, Live or Test mode, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
5
+ // each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
6
+ // made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
3
7
 
4
8
  import type { Look } from "../../looks/look.js";
5
9
  import type { AdminSummary } from "../store.js";
6
10
  import type { GameState } from "../switches.js";
7
11
  import { ApiError, createApi, type Api } from "./api.js";
8
- import { h, shortAddress } from "./dom.js";
12
+ import { copyKey, h, plural, shortAddress } from "./dom.js";
9
13
  import { gameCard } from "./gameCard.js";
14
+ import { icon, type IconName } from "./icons.js";
15
+ import { ordersView, type OrderFilter } from "./orders.js";
16
+ import { overviewView } from "./overview.js";
17
+ import { playersView } from "./players.js";
10
18
  import { shelfView } from "./shelf.js";
11
19
  import { signinsCard } from "./signins.js";
20
+ import { tabBar, type TabId } from "./tabs.js";
12
21
 
13
22
  export type MountOptions = {
14
23
  /** The game's looks now (its looks.json). The Shelf starts from them. */
@@ -40,35 +49,51 @@ export type MountedAdmin = {
40
49
  ready: Promise<void>;
41
50
  };
42
51
 
43
- const STATUS_WORDS: Record<string, string> = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
44
- const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
45
52
  const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
46
53
 
54
+ /** The drawn page, made at the first read that the owner may see. */
55
+ type Dashboard = { update(s: AdminSummary): void; setGame(state: GameState | null): void };
56
+
47
57
  export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdmin {
48
58
  const api: Api = createApi(options.api ?? "/api/admin", options.fetch);
49
- const gate = h("section", { id: "n133-gate", class: "admin-card", "aria-live": "polite" });
59
+ const gate = h("section", { id: "n133-gate", class: "admin-card gate", "aria-live": "polite" });
50
60
  const data = h("section", { id: "n133-data" });
51
61
  data.hidden = true;
52
62
  root.classList.add("n133-admin");
53
63
  root.replaceChildren(gate, data);
54
64
 
55
- let filter = "all";
56
65
  /** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
57
66
  let shelf: ReturnType<typeof shelfView> | null = null;
58
67
  /** The "Game" card keeps its own state for the life of the page (a payment check redraws the rest, not this). */
59
68
  let game: HTMLElement | null = null;
69
+ let gameState: GameState | null = null;
70
+ let dash: Dashboard | null = null;
60
71
 
61
72
  /** The card, made once. A fault in reading the two switches must not hide the orders: the card is left out and the rest still shows. */
62
73
  async function gameSection(): Promise<HTMLElement | null> {
63
74
  if (game) return game;
64
75
  try {
65
- game = gameCard(api, await api.get<GameState>("/game"), options.defaultsFile);
76
+ gameState = await api.get<GameState>("/game");
77
+ game = gameCard(api, gameState, options.defaultsFile, (state) => {
78
+ gameState = state;
79
+ dash?.setGame(state);
80
+ });
66
81
  } catch {
67
82
  game = null;
83
+ gameState = null;
68
84
  }
69
85
  return game;
70
86
  }
71
87
 
88
+ /** The gate's screen: an icon, a title and what to do. The data drawn before goes (a signed-out page holds none); the Shelf's draft and the Game card stay in memory. */
89
+ function showGate(kind: IconName, title: string, ...body: (Node | null)[]) {
90
+ data.hidden = true;
91
+ data.replaceChildren();
92
+ dash = null;
93
+ gate.hidden = false;
94
+ gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b): b is Node => b !== null));
95
+ }
96
+
72
97
  async function load(): Promise<void> {
73
98
  try {
74
99
  const s = await api.get<AdminSummary>("/summary");
@@ -77,43 +102,34 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
77
102
  render(s);
78
103
  } catch (e) {
79
104
  if (!(e instanceof ApiError)) {
105
+ // The data already drawn (if any) stays: a failed new read does not take it away.
80
106
  gate.hidden = false;
81
- gate.replaceChildren(h("p", {}, "The game's server did not answer. Try again."));
107
+ gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
82
108
  return;
83
109
  }
84
- data.hidden = true;
85
- gate.hidden = false;
86
110
  if (e.status === 403 && e.body.signedInAs) {
87
- gate.replaceChildren(
88
- h("h2", {}, "Not the game's address"),
89
- h("p", {}, `You are signed in as ${shortAddress(String(e.body.signedInAs))}. Only the game's owner address can open this page.`),
90
- signOutButton(),
91
- );
111
+ showGate("lock", "Not the game's address", h("p", {}, `You are signed in as ${shortAddress(String(e.body.signedInAs))}. Only the game's owner address can open this page.`), signOutButton());
92
112
  return;
93
113
  }
94
114
  if (e.status === 403 && e.body.reason === "nanoterra") {
95
- gate.replaceChildren(
96
- h("h2", {}, "You signed in from Nanoterra"),
97
- h("p", {}, "A Nanoterra sign-in names your address but proves no key, so it cannot open this page. Sign out, then sign in with the wallet that holds your game's address."),
98
- signOutButton(),
99
- );
115
+ showGate("lock", "You signed in from Nanoterra", h("p", {}, "A Nanoterra sign-in names your address but proves no key, so it cannot open this page. Sign out, then sign in with the wallet that holds your game's address."), signOutButton());
100
116
  return;
101
117
  }
102
118
  if (e.status === 403 && e.body.reason === "no_address") {
103
- gate.replaceChildren(h("h2", {}, "No owner address yet"), h("p", {}, e.message), h("p", { class: "fine" }, options.noAddressHelp ?? "Set the game's owner address, then deploy again."));
119
+ showGate("alert", "No owner address yet", h("p", {}, e.message), h("p", { class: "fine" }, options.noAddressHelp ?? "Set the game's owner address, then deploy again."));
104
120
  return;
105
121
  }
106
122
  if (e.status === 403) {
107
- gate.replaceChildren(h("h2", {}, "This page is closed to you"), h("p", {}, e.message), signOutButton());
123
+ showGate("lock", "This page is closed to you", h("p", {}, e.message), signOutButton());
108
124
  return;
109
125
  }
110
126
  if (e.status === 401) {
111
- const into = h("div", {});
112
- gate.replaceChildren(h("h2", {}, "Sign in with your game's address"), h("p", {}, e.message), into);
127
+ const into = h("div", { class: "gate-signin" });
128
+ showGate("lock", "Sign in with your game's address", h("p", {}, e.message), into);
113
129
  options.signIn(into, () => void load());
114
130
  return;
115
131
  }
116
- gate.replaceChildren(h("p", {}, e.message));
132
+ showGate("alert", "Something went wrong", h("p", {}, e.message));
117
133
  }
118
134
  }
119
135
 
@@ -128,6 +144,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
128
144
  void load();
129
145
  },
130
146
  },
147
+ icon("out", 18),
131
148
  "Sign out",
132
149
  );
133
150
  }
@@ -139,89 +156,91 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
139
156
  return shelf.el;
140
157
  }
141
158
 
142
- function render(s: AdminSummary): void {
143
- data.hidden = false;
144
- const check = h("button", { class: "btn btn-primary", type: "button" }, "Check payments now");
145
- const checkNote = h("span", { class: "fine", role: "status" });
146
- check.addEventListener("click", async () => {
147
- check.disabled = true;
148
- checkNote.textContent = "Checking…";
149
- try {
150
- const r = await api.post<{ scan: { scanned: boolean; settled: unknown[]; error?: string }; summary: AdminSummary }>("/check");
151
- render(r.summary);
152
- } catch (e) {
153
- checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
154
- check.disabled = false;
155
- }
156
- });
157
-
158
- const tabs = h("div", { class: "tabs", role: "group", "aria-label": "Show orders" });
159
- for (const key of ["all", "open", "paid", "expired", "paid_late"]) {
160
- const n = key === "all" ? s.orders.length : s.counts[key as keyof AdminSummary["counts"]];
161
- tabs.append(
162
- h(
163
- "button",
164
- {
165
- class: `tab${filter === key ? " tab-on" : ""}`,
166
- type: "button",
167
- "aria-pressed": filter === key ? "true" : "false",
168
- onclick: () => {
169
- filter = key;
170
- render(s);
171
- },
172
- },
173
- `${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`,
174
- ),
159
+ /** An outfit's body colour, while the game still has the look (for the dots in the lists); none for an item of another wear slot. */
160
+ const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change?.body ?? null;
161
+
162
+ function build(): Dashboard {
163
+ const tabs = tabBar("overview");
164
+ const go = (tab: TabId, filter?: OrderFilter) => {
165
+ if (filter) orders.setFilter(filter);
166
+ tabs.select(tab);
167
+ };
168
+ const overview = overviewView(options.looks, colourOf, go);
169
+ const orders = ordersView(api, colourOf, (s) => render(s));
170
+ const players = playersView(colourOf);
171
+ tabs.panels.overview.append(overview.el);
172
+ tabs.panels.orders.append(orders.el);
173
+ tabs.panels.players.append(players.el);
174
+
175
+ const who = h("div", { class: "who" });
176
+ const chips = h("div", { class: "chips" });
177
+ const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, signOutButton()));
178
+ const banner = h("div", { class: "banner warnings", role: "alert" });
179
+ const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
180
+ const settings = h("div", { class: "settings-grid" });
181
+ tabs.panels.settings.append(settings);
182
+ data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
183
+
184
+ const kpi = (cls: string, mark: IconName, label: string, value: string, sub: string, open: () => void, alert = false) =>
185
+ h("button", { class: `kpi kpi-${cls}${alert ? " is-alert" : ""}`, type: "button", onclick: open }, h("span", { class: "kpi-top" }, h("span", { class: "kpi-icon", "aria-hidden": "true" }, icon(mark, 18)), h("span", { class: "kpi-label" }, label)), h("strong", { class: "kpi-value" }, value), h("span", { class: "kpi-sub" }, sub));
186
+
187
+ function about(s: AdminSummary): HTMLElement {
188
+ const facts: [string, Node | string][] = [];
189
+ if (s.address) facts.push(["Signed in as", h("span", { class: "fact-address" }, h("code", {}, s.address), copyKey(s.address, "Copy your address", icon("copy", 14)))]);
190
+ facts.push(["Mode", s.testMode ? "Test mode: payments are not real" : "Live: payments are real"]);
191
+ if (s.turnstile !== undefined) facts.push(["Bot check", s.turnstile ? "On" : "Off"]);
192
+ if (s.nodes?.length) facts.push(["Payments are checked on", s.nodes.join(", ")]);
193
+ if (s.rate) facts.push(["Last price", `$${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}`]);
194
+ return h(
195
+ "section",
196
+ { class: "admin-card about-card", "aria-labelledby": "n133-about-title" },
197
+ h("h2", { id: "n133-about-title" }, "About this admin"),
198
+ h("dl", { class: "facts" }, ...facts.map(([k, v]) => h("div", {}, h("dt", {}, k), h("dd", {}, v)))),
199
+ signOutButton(),
175
200
  );
176
201
  }
177
202
 
178
- const rows = s.orders.filter((o) => filter === "all" || o.status === filter);
179
- const orders = h("div", { class: "orders" });
180
- if (!rows.length) orders.append(h("p", { class: "fine" }, "No orders here yet."));
181
- for (const o of rows) {
182
- orders.append(
183
- h(
184
- "article",
185
- { class: `order order-${o.status}` },
186
- h("div", { class: "order-top" }, h("strong", {}, o.look), h("span", { class: `badge badge-${o.status}` }, STATUS_WORDS[o.status] ?? o.status)),
187
- h("div", { class: "order-amount" }, `${o.xno} XNO · $${o.usd}`),
188
- h("div", { class: "fine" }, `Player ${shortAddress(o.player)} · made ${when(o.createdAt)}`),
189
- o.paidAt ? h("div", { class: "fine" }, `Payment seen ${when(o.paidAt)}${o.payer ? ` from ${shortAddress(o.payer)}` : ""}`) : null,
190
- o.status === "paid_late" && o.payer ? h("div", { class: "late-note" }, "Send this amount back by hand to ", h("code", {}, o.payer), ". The game holds no key, so it cannot send it.") : null,
191
- o.hash ? h("div", { class: "fine hash" }, `Block ${o.hash}`) : null,
192
- ),
193
- );
194
- }
203
+ return {
204
+ setGame: (state) => overview.setGame(state),
205
+ update(s) {
206
+ who.replaceChildren(h("span", { class: "who-label" }, "Signed in as"), s.address ? h("span", { class: "who-address" }, h("code", { title: s.address }, shortAddress(s.address)), copyKey(s.address, "Copy your address", icon("copy", 14))) : h("span", {}, "the game's owner"));
207
+ chips.replaceChildren(
208
+ h("span", { class: `chip ${s.testMode ? "chip-warn" : "chip-ok"}`, title: s.testMode ? "Payments are not real" : "Payments are real" }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.testMode ? "Test mode" : "Live"),
209
+ s.turnstile === undefined ? "" : h("span", { class: `chip ${s.turnstile ? "chip-ok" : "chip-warn"}` }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.turnstile ? "Bot check on" : "Bot check off"),
210
+ );
211
+ banner.hidden = !s.warnings.length;
212
+ banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))));
213
+
214
+ const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
215
+ const late = s.totals.paidLate.count;
216
+ kpis.replaceChildren(
217
+ kpi("earned", "coin", "Earned", `${s.totals.paid.xno} XNO`, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`, () => go("orders", "paid")),
218
+ kpi("open", "clock", "Open now", String(s.counts.open), `${s.counts.expired} expired`, () => go("orders", "open")),
219
+ kpi("late", "back", "To send back", `${s.totals.paidLate.xno} XNO`, late ? `$${s.totals.paidLate.usd} · ${plural(late, "order")}` : "Nothing to send back", () => go("orders", "paid_late"), late > 0),
220
+ kpi("players", "players", "Players", String(s.players.length), `${plural(ownedLooks, "look")} owned`, () => go("players")),
221
+ );
195
222
 
196
- const players = h("div", { class: "players" });
197
- if (!s.players.length) players.append(h("p", { class: "fine" }, "Nobody owns a paid look yet."));
198
- for (const p of s.players) players.append(h("div", { class: "player" }, h("code", {}, p.player), h("span", {}, p.looks.map((l) => l.name).join(", "))));
223
+ tabs.setCount("cosmetics", options.looks.length);
224
+ tabs.setCount("orders", s.orders.length);
225
+ tabs.setCount("players", s.players.length);
226
+ overview.update(s);
227
+ overview.setGame(gameState);
228
+ const shelfEl = shelfCard(s);
229
+ if (shelfEl.parentNode !== tabs.panels.cosmetics) tabs.panels.cosmetics.replaceChildren(shelfEl);
230
+ orders.update(s);
231
+ players.update(s);
232
+ // The Game card is the same element at every redraw: moving it keeps its state.
233
+ settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p): p is HTMLElement => p !== null));
234
+ },
235
+ };
236
+ }
199
237
 
200
- const parts: (Node | null)[] = [
201
- s.warnings.length ? h("section", { class: "admin-card warnings", role: "alert" }, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))) : null,
202
- game,
203
- h(
204
- "section",
205
- { class: "totals" },
206
- h("div", { class: "total" }, h("span", {}, "Paid"), h("strong", {}, `${s.totals.paid.xno} XNO`), h("small", {}, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`)),
207
- h("div", { class: "total" }, h("span", {}, "Paid late (send back)"), h("strong", {}, `${s.totals.paidLate.xno} XNO`), h("small", {}, `$${s.totals.paidLate.usd} · ${plural(s.totals.paidLate.count, "order")}`)),
208
- h("div", { class: "total" }, h("span", {}, "Open now"), h("strong", {}, String(s.counts.open)), h("small", {}, `${s.counts.expired} expired`)),
209
- ),
210
- shelfCard(s),
211
- h("section", { class: "admin-card" }, h("div", { class: "card-head" }, h("h2", {}, "Orders"), h("div", {}, ...(s.canCheck === false ? [] : [check, " ", checkNote]))), tabs, orders),
212
- h("section", { class: "admin-card" }, h("h2", {}, "Owned looks"), players),
213
- signinsCard(s, api, () => void load(), options.loginsNote),
214
- h(
215
- "section",
216
- { class: "admin-card fine" },
217
- s.address ? h("p", {}, "Signed in as ", h("code", {}, s.address), ".") : null,
218
- s.nodes?.length ? h("p", {}, `Payments are checked on: ${s.nodes.join(", ")}.`) : null,
219
- s.rate ? h("p", {}, `Last price: $${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}.`) : null,
220
- signOutButton(),
221
- ),
222
- ];
223
- data.replaceChildren(...parts.filter((p): p is Node => p !== null));
238
+ function render(s: AdminSummary): void {
239
+ data.hidden = false;
240
+ dash ??= build();
241
+ dash.update(s);
224
242
  }
225
243
 
226
244
  return { reload: () => load(), ready: load() };
227
245
  }
246
+
@@ -0,0 +1,172 @@
1
+ // The Orders tab of /admin: every order of the game (the summary gives the newest 500), a filter by status with its count, a search (a look, a player's or a
2
+ // payer's address, a block), and "Check payments now" when the game gave a check. A row is a card on a phone and a line of a table on a wide screen. A
3
+ // payment that came after its order ran out says what to do: the game holds no key, so the owner sends the amount back by hand.
4
+
5
+ import type { AdminOrder, AdminSummary } from "../store.js";
6
+ import type { Api } from "./api.js";
7
+ import { ago, copyKey, h, plural, shortAddress } from "./dom.js";
8
+ import { icon } from "./icons.js";
9
+
10
+ export type OrderFilter = "all" | "open" | "paid" | "expired" | "paid_late";
11
+
12
+ /** How many orders the list draws at first, and how many more each "Show more" adds. */
13
+ export const ORDERS_PAGE = 20;
14
+
15
+ export const STATUS_WORDS: Record<string, string> = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
16
+ const FILTERS: readonly OrderFilter[] = ["all", "paid", "open", "paid_late", "expired"];
17
+
18
+ const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
19
+ /** "A1B2C3D4…E5F6A7" */
20
+ const shortHash = (hash: string) => (hash.length > 16 ? `${hash.slice(0, 8)}…${hash.slice(-6)}` : hash);
21
+
22
+ /** Whether an order matches a search: its look's name or id, the player's or the payer's address, or its block. Upper or lower case does not matter. Pure. */
23
+ export function orderMatches(o: AdminOrder, query: string): boolean {
24
+ const q = query.trim().toLowerCase();
25
+ if (!q) return true;
26
+ return [o.look, o.lookId, o.player, o.payer ?? "", o.hash ?? ""].some((v) => v.toLowerCase().includes(q));
27
+ }
28
+
29
+ /** A status as a coloured badge. */
30
+ export const badge = (status: string) => h("span", { class: `badge badge-${status}` }, STATUS_WORDS[status] ?? status);
31
+
32
+ /** One order: a card on a phone, a line of the table on a wide screen. `colourOf` gives the look's body colour for its dot, when the game still has the look. */
33
+ export function orderRow(o: AdminOrder, colourOf: (lookId: string) => string | null, compact = false): HTMLElement {
34
+ const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
35
+ const c = colourOf(o.lookId);
36
+ if (c) dot.style.setProperty("--swatch", c);
37
+ const row = h(
38
+ "article",
39
+ { class: `order order-${o.status}${compact ? " is-compact" : ""}` },
40
+ h("div", { class: "order-look" }, dot, h("strong", {}, o.look)),
41
+ h("div", { class: "order-status" }, badge(o.status)),
42
+ h("div", { class: "order-amount" }, h("strong", {}, `${o.xno} XNO`), h("span", { class: "fine" }, `$${o.usd}`)),
43
+ h("div", { class: "order-player" }, h("span", { class: "fine cell-label" }, "Player "), h("code", { title: o.player }, shortAddress(o.player)), compact ? null : copyKey(o.player, "Copy the player's address", icon("copy", 14))),
44
+ compact
45
+ ? h("div", { class: "order-when" }, o.paidAt ? h("span", { title: when(o.paidAt) }, `Paid ${ago(o.paidAt)}`) : h("span", { title: when(o.createdAt) }, `Made ${ago(o.createdAt)}`))
46
+ : h(
47
+ "div",
48
+ { class: "order-when" },
49
+ h("span", { title: when(o.createdAt) }, `Made ${ago(o.createdAt)}`),
50
+ o.paidAt ? h("span", { class: "fine", title: when(o.paidAt) }, `Paid ${ago(o.paidAt)}${o.payer && o.payer !== o.player ? ` from ${shortAddress(o.payer)}` : ""}`) : null,
51
+ ),
52
+ );
53
+ if (compact) return row;
54
+ if (o.status === "paid_late" && o.payer)
55
+ row.append(h("div", { class: "late-note order-extra" }, icon("back", 16), h("span", {}, "Send this amount back by hand to ", h("code", {}, o.payer), ". The game holds no key, so it cannot send it."), copyKey(o.payer, "Copy the address to send it back to", icon("copy", 14))));
56
+ if (o.hash) row.append(h("div", { class: "fine order-extra order-hash" }, h("span", {}, "Block "), h("code", { title: o.hash }, shortHash(o.hash)), copyKey(o.hash, "Copy the block hash", icon("copy", 14))));
57
+ return row;
58
+ }
59
+
60
+ export type OrdersView = {
61
+ el: HTMLElement;
62
+ update(s: AdminSummary): void;
63
+ /** Shows only one status (a press on a number of the Overview does it). */
64
+ setFilter(f: OrderFilter): void;
65
+ };
66
+
67
+ /**
68
+ * The tab's content, made once: its filter, its search and its check keep their state when the summary is read again. `colourOf` gives a look's body colour;
69
+ * `onSummary` hears the summary that "Check payments now" answers with (the panel draws it everywhere).
70
+ */
71
+ export function ordersView(api: Api, colourOf: (lookId: string) => string | null, onSummary: (s: AdminSummary) => void): OrdersView {
72
+ let s: AdminSummary | null = null;
73
+ let filter: OrderFilter = "all";
74
+ let query = "";
75
+ let shown = ORDERS_PAGE;
76
+
77
+ const check = h("button", { class: "btn btn-primary", type: "button" }, icon("refresh", 18), "Check payments now");
78
+ const checkNote = h("span", { class: "fine", role: "status" });
79
+ check.addEventListener("click", async () => {
80
+ check.disabled = true;
81
+ checkNote.textContent = "Checking…";
82
+ try {
83
+ const r = await api.post<{ scan: { scanned: boolean; settled: unknown[]; error?: string }; summary: AdminSummary }>("/check");
84
+ checkNote.textContent = !r.scan?.scanned ? "The check did not finish. Try again in a moment." : r.scan.settled.length ? `Checked: ${plural(r.scan.settled.length, "payment")} found.` : "Checked: no new payment.";
85
+ onSummary(r.summary);
86
+ } catch (e) {
87
+ checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
88
+ } finally {
89
+ check.disabled = false;
90
+ }
91
+ });
92
+
93
+ const seg = h("div", { class: "seg tabs", role: "group", "aria-label": "Show orders" });
94
+ const search = h("input", { class: "input search-input", type: "search", placeholder: "Look, address or block", "aria-label": "Search the orders", autocomplete: "off", spellcheck: false });
95
+ search.addEventListener("input", () => {
96
+ query = search.value;
97
+ shown = ORDERS_PAGE;
98
+ drawList();
99
+ });
100
+ const count = h("p", { class: "fine list-count", role: "status", "aria-live": "polite" });
101
+ const list = h("div", { class: "orders" });
102
+ // The check key is there only when the game gave a check (the summary's canCheck).
103
+ const keys = h("div", { class: "card-keys" });
104
+ const head = h("div", { class: "card-head" }, h("div", { class: "card-title" }, h("h2", {}, "Orders"), h("p", { class: "fine card-sub" }, "Every order your game made, newest first.")), keys);
105
+ const el = h(
106
+ "section",
107
+ { class: "admin-card orders-card" },
108
+ head,
109
+ h("div", { class: "toolbar" }, seg, h("label", { class: "search" }, icon("search", 18), search)),
110
+ count,
111
+ h("div", { class: "order-table-head", "aria-hidden": "true" }, h("span", {}, "Look"), h("span", {}, "Status"), h("span", {}, "Amount"), h("span", {}, "Player"), h("span", {}, "When")),
112
+ list,
113
+ );
114
+
115
+ function drawFilter() {
116
+ if (!s) return;
117
+ const sum = s;
118
+ seg.replaceChildren(
119
+ ...FILTERS.map((key) => {
120
+ const n = key === "all" ? sum.orders.length : sum.counts[key];
121
+ return h(
122
+ "button",
123
+ {
124
+ class: `seg-btn tab${filter === key ? " is-on tab-on" : ""}${key === "paid_late" && n > 0 ? " is-alert" : ""}`,
125
+ type: "button",
126
+ "aria-pressed": filter === key ? "true" : "false",
127
+ onclick: () => {
128
+ filter = key;
129
+ shown = ORDERS_PAGE;
130
+ drawFilter();
131
+ drawList();
132
+ },
133
+ },
134
+ `${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`,
135
+ );
136
+ }),
137
+ );
138
+ }
139
+
140
+ function drawList() {
141
+ if (!s) return;
142
+ const rows = s.orders.filter((o) => (filter === "all" || o.status === filter) && orderMatches(o, query));
143
+ const total = s.orders.filter((o) => filter === "all" || o.status === filter).length;
144
+ count.textContent = query.trim() ? `${plural(rows.length, "order")} of ${total} match “${query.trim()}”.` : "";
145
+ count.hidden = !query.trim();
146
+ list.replaceChildren(...rows.slice(0, shown).map((o) => orderRow(o, colourOf)));
147
+ if (!rows.length) list.append(h("p", { class: "empty" }, query.trim() ? "No order matches your search." : "No orders here yet."));
148
+ if (rows.length > shown) {
149
+ const more = Math.min(ORDERS_PAGE, rows.length - shown);
150
+ list.append(
151
+ h("button", { class: "btn more-key", type: "button", onclick: () => ((shown += ORDERS_PAGE), drawList()) }, `Show ${more} more`, rows.length - shown > more ? h("span", { class: "fine" }, `of ${rows.length - shown}`) : null),
152
+ );
153
+ }
154
+ }
155
+
156
+ return {
157
+ el,
158
+ update(next) {
159
+ s = next;
160
+ if (next.canCheck === false) keys.replaceChildren();
161
+ else if (check.parentNode !== keys) keys.replaceChildren(check, checkNote);
162
+ drawFilter();
163
+ drawList();
164
+ },
165
+ setFilter(f) {
166
+ filter = f;
167
+ shown = ORDERS_PAGE;
168
+ drawFilter();
169
+ drawList();
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,134 @@
1
+ // The Overview tab of /admin: the game at a glance. The best-selling looks with their share of the money, the last five orders, the two game switches as On or
2
+ // Off, and the shelf in numbers; each part has a key to its own tab. A game with no sale yet gets three first steps instead of empty lists.
3
+
4
+ import type { Look } from "../../looks/look.js";
5
+ import { priceText } from "../../looks/ladder.js";
6
+ import type { AdminSummary } from "../store.js";
7
+ import { SWITCHES, stateOf, type GameState } from "../switches.js";
8
+ import { dollars, h, plural } from "./dom.js";
9
+ import { icon } from "./icons.js";
10
+ import { orderRow, type OrderFilter } from "./orders.js";
11
+ import { shelfStats } from "./shelfData.js";
12
+ import type { TabId } from "./tabs.js";
13
+
14
+ /** A best seller: a look id, its name, its paid orders and their cents, its owners. */
15
+ export type BestSeller = { id: string; name: string; paid: number; cents: number; owners: number };
16
+
17
+ /** The looks that sold, best first (by money, then by owners), at most `n`. A look that is no longer in looks.json keeps the name its orders give. Pure. */
18
+ export function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks: readonly Look[], n = 5): BestSeller[] {
19
+ const names = new Map<string, string>();
20
+ for (const o of s.orders) names.set(o.lookId, o.look);
21
+ for (const l of looks) names.set(l.id, l.name);
22
+ return [...shelfStats(s.orders, s.players)]
23
+ .filter(([, st]) => st.paid > 0)
24
+ .map(([id, st]) => ({ id, name: names.get(id) ?? id, paid: st.paid, cents: st.paidCents, owners: st.owners }))
25
+ .sort((a, b) => b.cents - a.cents || b.owners - a.owners || a.name.localeCompare(b.name))
26
+ .slice(0, n);
27
+ }
28
+
29
+ export type OverviewView = {
30
+ el: HTMLElement;
31
+ update(s: AdminSummary): void;
32
+ /** The two switches as they are now (null: they could not be read). */
33
+ setGame(state: GameState | null): void;
34
+ };
35
+
36
+ export function overviewView(looks: readonly Look[], colourOf: (lookId: string) => string | null, go: (tab: TabId, filter?: OrderFilter) => void): OverviewView {
37
+ let game: GameState | null = null;
38
+ const sellers = h("section", { class: "admin-card best-card", "aria-labelledby": "n133-best-title" });
39
+ const recent = h("section", { class: "admin-card recent-card", "aria-labelledby": "n133-recent-title" });
40
+ const gameBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-game" });
41
+ const shelfBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-shelf" });
42
+ const start = h("section", { class: "admin-card start-card", "aria-labelledby": "n133-start-title" });
43
+ const main = h("div", { class: "overview-main" }, start, sellers, recent);
44
+ const side = h("div", { class: "overview-side" }, gameBox, shelfBox);
45
+ const el = h("div", { class: "overview" }, main, side);
46
+
47
+ const linkKey = (words: string, to: () => void) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
48
+
49
+ function drawGame() {
50
+ const rows = (["character", "signin"] as const).map((id) => {
51
+ const on = game ? stateOf(id, game) : null;
52
+ return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
53
+ });
54
+ gameBox.replaceChildren(
55
+ h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))),
56
+ game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The two switches could not be read. Reload the page to try again."),
57
+ );
58
+ }
59
+
60
+ function drawShelf() {
61
+ const sale = looks.filter((l) => l.priceUsd > 0);
62
+ const prices = sale.map((l) => l.priceUsd);
63
+ const range = prices.length ? (Math.min(...prices) === Math.max(...prices) ? priceText(prices[0]) : `${priceText(Math.min(...prices))} to ${priceText(Math.max(...prices))}`) : "none";
64
+ shelfBox.replaceChildren(
65
+ h("div", { class: "card-head" }, h("h2", { id: "n133-glance-shelf" }, "Shelf"), linkKey("Open", () => go("cosmetics"))),
66
+ h(
67
+ "ul",
68
+ { class: "glance-list" },
69
+ h("li", {}, h("span", {}, "Looks"), h("strong", {}, String(looks.length))),
70
+ h("li", {}, h("span", {}, "For sale"), h("strong", {}, String(sale.length))),
71
+ h("li", {}, h("span", {}, "Prices"), h("strong", {}, range)),
72
+ ),
73
+ );
74
+ }
75
+
76
+ drawGame();
77
+ drawShelf();
78
+
79
+ return {
80
+ el,
81
+ setGame(state) {
82
+ game = state;
83
+ drawGame();
84
+ },
85
+ update(s) {
86
+ const best = bestSellers(s, looks);
87
+ const total = best.length ? [...shelfStats(s.orders, s.players).values()].reduce((n, x) => n + x.paidCents, 0) : 0;
88
+ start.hidden = s.orders.length > 0;
89
+ start.replaceChildren(
90
+ h("div", { class: "start-icon", "aria-hidden": "true" }, icon("sparkle", 28)),
91
+ h("h2", { id: "n133-start-title" }, "No sales yet"),
92
+ h("p", { class: "fine" }, "When a player buys a look, the money and the order show here. Three things to do first:"),
93
+ h(
94
+ "ol",
95
+ { class: "start-steps" },
96
+ h("li", {}, h("strong", {}, "Check your looks and prices."), " ", linkKey("Open the shelf", () => go("cosmetics"))),
97
+ h("li", {}, h("strong", {}, "Choose the two game switches."), " ", linkKey("Open the settings", () => go("settings"))),
98
+ h("li", {}, h("strong", {}, "Share your game's address"), " with your players."),
99
+ ),
100
+ );
101
+
102
+ sellers.hidden = !best.length;
103
+ sellers.replaceChildren(
104
+ h("div", { class: "card-head" }, h("h2", { id: "n133-best-title" }, "Best sellers"), linkKey("All looks", () => go("cosmetics"))),
105
+ h(
106
+ "ol",
107
+ { class: "best-list" },
108
+ ...best.map((b, i) => {
109
+ const share = total ? Math.round((b.cents / total) * 100) : 0;
110
+ const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
111
+ const c = colourOf(b.id);
112
+ if (c) dot.style.setProperty("--swatch", c);
113
+ const bar = h("div", { class: "share-bar" });
114
+ bar.style.setProperty("--share", `${share}%`);
115
+ return h(
116
+ "li",
117
+ {},
118
+ h("span", { class: "best-rank" }, String(i + 1)),
119
+ dot,
120
+ h("div", { class: "best-main" }, h("div", { class: "best-top" }, h("strong", {}, b.name), h("span", { class: "best-money" }, dollars(b.cents))), h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${plural(b.paid, "sale")} · ${share}%`))),
121
+ );
122
+ }),
123
+ ),
124
+ );
125
+
126
+ const last = s.orders.slice(0, 5);
127
+ recent.hidden = !last.length;
128
+ recent.replaceChildren(
129
+ h("div", { class: "card-head" }, h("h2", { id: "n133-recent-title" }, "Recent orders"), linkKey("See all orders", () => go("orders", "all"))),
130
+ h("div", { class: "orders is-compact" }, ...last.map((o) => orderRow(o, colourOf, true))),
131
+ );
132
+ },
133
+ };
134
+ }