@nano133/nanoterra 0.9.0 → 0.10.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 (58) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -12
  3. package/ai/SPEC.md +7 -1
  4. package/dist/admin/amount.d.ts +4 -0
  5. package/dist/admin/amount.js +4 -0
  6. package/dist/admin/panel/dom.d.ts +1 -1
  7. package/dist/admin/panel/dom.js +3 -2
  8. package/dist/admin/panel/shelf.js +1 -1
  9. package/dist/admin/panel/shelfData.d.ts +1 -1
  10. package/dist/admin/panel/shelfData.js +6 -6
  11. package/dist/admin/summary.d.ts +1 -1
  12. package/dist/admin/summary.js +8 -8
  13. package/dist/looks/index.d.ts +1 -1
  14. package/dist/looks/index.js +1 -1
  15. package/dist/looks/ladder.d.ts +1 -1
  16. package/dist/looks/ladder.js +7 -7
  17. package/dist/looks/look.d.ts +7 -2
  18. package/dist/looks/look.js +10 -3
  19. package/dist/player-signin/panel/index.d.ts +2 -2
  20. package/dist/player-signin/panel/index.js +3 -2
  21. package/dist/player-signin/panel/mount.d.ts +1 -1
  22. package/dist/player-signin/panel/mount.js +5 -3
  23. package/dist/player-signin/panel/view.d.ts +3 -0
  24. package/dist/player-signin/panel/view.js +49 -46
  25. package/dist/player-signin/panel/words.d.ts +96 -1
  26. package/dist/player-signin/panel/words.js +109 -24
  27. package/dist/shop/handler.js +2 -2
  28. package/dist/shop/panel/index.d.ts +2 -0
  29. package/dist/shop/panel/index.js +3 -0
  30. package/dist/shop/panel/looks.d.ts +3 -0
  31. package/dist/shop/panel/looks.js +11 -11
  32. package/dist/shop/panel/mount.d.ts +18 -2
  33. package/dist/shop/panel/mount.js +48 -21
  34. package/dist/shop/panel/pay.d.ts +3 -0
  35. package/dist/shop/panel/pay.js +38 -25
  36. package/dist/shop/panel/words.d.ts +89 -0
  37. package/dist/shop/panel/words.js +79 -0
  38. package/package.json +1 -1
  39. package/src/admin/amount.ts +6 -0
  40. package/src/admin/panel/dom.ts +4 -2
  41. package/src/admin/panel/shelf.ts +1 -1
  42. package/src/admin/panel/shelfData.ts +6 -6
  43. package/src/admin/summary.ts +8 -8
  44. package/src/looks/index.ts +1 -1
  45. package/src/looks/ladder.ts +7 -6
  46. package/src/looks/look.ts +12 -3
  47. package/src/player-signin/panel/index.ts +4 -3
  48. package/src/player-signin/panel/mount.ts +6 -4
  49. package/src/player-signin/panel/style.css +22 -17
  50. package/src/player-signin/panel/view.ts +60 -55
  51. package/src/player-signin/panel/words.ts +202 -24
  52. package/src/shop/handler.ts +2 -2
  53. package/src/shop/panel/index.ts +4 -0
  54. package/src/shop/panel/looks.ts +14 -14
  55. package/src/shop/panel/mount.ts +60 -21
  56. package/src/shop/panel/pay.ts +51 -30
  57. package/src/shop/panel/style.css +24 -20
  58. package/src/shop/panel/words.ts +172 -0
@@ -11,6 +11,8 @@
11
11
  //
12
12
  // The seed lives in local variables of one sign-in only: never in storage, a log, an error text or an address. The page remembers only the passkeys'
13
13
  // ids and the last way (./memo.ts).
14
+ //
15
+ // `words` changes any word of the sheet (./words.ts: PLAYER_SIGNIN_WORDS are the defaults), for a game's own words or language.
14
16
  import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport } from "@nano133/signin/passkey";
15
17
  import { ApiError, createApi } from "../../admin/panel/api.js";
16
18
  import { h, shortAddress } from "../../admin/panel/dom.js";
@@ -19,7 +21,7 @@ import { checkScheme } from "../scheme-check.js";
19
21
  import { previewHost } from "../host.js";
20
22
  import { WAYS } from "../types.js";
21
23
  import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
22
- import { faultWords } from "./words.js";
24
+ import { faultWords, playerSigninWords } from "./words.js";
23
25
  /** The wallets, with letters, not logos: no wallet's licence clearly allows its logo. */
24
26
  const WALLETS = {
25
27
  berry: { name: "Berry Wallet", mark: "B" },
@@ -59,6 +61,7 @@ export function signInView(options) {
59
61
  const passkeyOn = options.passkey ?? !previewHost(rpId);
60
62
  const scheme = passkeyOn ? checkScheme(options.scheme) : null;
61
63
  const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
64
+ const w = playerSigninWords(options.words);
62
65
  let stopped = false;
63
66
  let timer;
64
67
  let prompt = null;
@@ -77,7 +80,7 @@ export function signInView(options) {
77
80
  node.toggleAttribute("aria-busy", on);
78
81
  };
79
82
  const key = (text, onclick, kind = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
80
- const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, "Back"));
83
+ const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, w.back));
81
84
  const title = (text) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
82
85
  const focusFirst = () => (area.querySelector("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
83
86
  const setup = () => ({ rpId, rpName: options.name, scheme: scheme, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
@@ -94,7 +97,7 @@ export function signInView(options) {
94
97
  function done(player) {
95
98
  stopWaiting();
96
99
  current = null;
97
- area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in as ${shortAddress(player.address)}`)));
100
+ area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, w.signedInAs(shortAddress(player.address)))));
98
101
  say("");
99
102
  options.onSignedIn?.(player);
100
103
  }
@@ -102,29 +105,29 @@ export function signInView(options) {
102
105
  function passkeyTile() {
103
106
  const off = support === "no";
104
107
  const last = readMemo(rpId).last === "passkey";
105
- return h("button", { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() }, h("span", { class: "n133s-pk-mark" }, keyIcon()), h("span", { class: "n133s-pk-words" }, h("strong", {}, "Use a passkey"), h("small", {}, off ? "Not on this browser" : "Face ID, Touch ID or your device PIN")), last ? h("span", { class: "n133s-last" }, "Last used") : null);
108
+ return h("button", { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() }, h("span", { class: "n133s-pk-mark" }, keyIcon()), h("span", { class: "n133s-pk-words" }, h("strong", {}, w.usePasskey), h("small", {}, off ? w.passkeyOff : w.passkeyHint)), last ? h("span", { class: "n133s-last" }, w.lastUsed) : null);
106
109
  }
107
110
  function walletTiles() {
108
111
  const last = readMemo(rpId).last;
109
- return h("div", { class: "n133s-tiles" }, ...ways.map((w) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(w) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[w].mark), h("span", { class: "n133s-name" }, WALLETS[w].name), last === w ? h("span", { class: "n133s-last" }, "Last used") : null)));
112
+ return h("div", { class: "n133s-tiles" }, ...ways.map((way) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(way) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[way].mark), h("span", { class: "n133s-name" }, WALLETS[way].name), last === way ? h("span", { class: "n133s-last" }, w.lastUsed) : null)));
110
113
  }
111
114
  function showWays() {
112
115
  stopWaiting();
113
116
  busy(false);
114
117
  const pk = passkeyOn ? passkeyTile() : null;
115
118
  const first = pk && support !== "no";
116
- const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? "Or a wallet app" : "Choose your wallet"), walletTiles()] : [];
117
- fill(h("p", { class: "n133s-lead" }, "No password and no payment."), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, "Or a passkey") : null, !first ? pk : null);
119
+ const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? w.orWallet : w.chooseWallet), walletTiles()] : [];
120
+ fill(h("p", { class: "n133s-lead" }, w.lead), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, w.orPasskey) : null, !first ? pk : null);
118
121
  say("");
119
122
  }
120
123
  // ---- the passkey ----
121
124
  function showFault(e) {
122
125
  busy(false);
123
126
  const f = passkeyFault(e);
124
- const w = faultWords(f.code, rpId);
125
- if (w.next === "stay")
126
- return showPasskey(w.text);
127
- fill(backKey(), title(w.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, w.text), ways.length ? h("p", { class: "n133s-or" }, "Sign in with a wallet app") : null, ways.length ? walletTiles() : null);
127
+ const fw = faultWords(f.code, rpId, w);
128
+ if (fw.next === "stay")
129
+ return showPasskey(fw.text);
130
+ fill(backKey(), title(fw.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, fw.text), ways.length ? h("p", { class: "n133s-or" }, w.faultWallets) : null, ways.length ? walletTiles() : null);
128
131
  say("");
129
132
  focusFirst();
130
133
  }
@@ -140,15 +143,15 @@ export function signInView(options) {
140
143
  if (!hostFits(location.hostname, rpId))
141
144
  return showFault({ name: "SecurityError" });
142
145
  const known = readMemo(rpId).ids.length > 0;
143
- const use = key("Use my passkey", () => void usePasskey(), known ? "primary" : "plain");
144
- const make = key("Make a passkey", () => void makeNew(), known ? "plain" : "primary");
145
- area.replaceChildren(backKey(), title("Passkey"), h("p", { class: "n133s-text" }, "Face ID, Touch ID or your device PIN. The passkey stays on your device: the game never sees it."), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
146
+ const use = key(w.useMine, () => void usePasskey(), known ? "primary" : "plain");
147
+ const make = key(w.makeOne, () => void makeNew(), known ? "plain" : "primary");
148
+ area.replaceChildren(backKey(), title(w.passkeyTitle), h("p", { class: "n133s-text" }, w.passkeyText), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
146
149
  say(note ?? "");
147
150
  focusFirst();
148
151
  }
149
152
  async function usePasskey() {
150
153
  busy(true);
151
- say("Waiting for your passkey…");
154
+ say(w.waitingPasskey);
152
155
  prompt = new AbortController();
153
156
  try {
154
157
  const p = await openPasskey(setup(), prompt.signal);
@@ -163,7 +166,7 @@ export function signInView(options) {
163
166
  }
164
167
  async function makeNew() {
165
168
  busy(true);
166
- say("Making your passkey…");
169
+ say(w.makingPasskey);
167
170
  prompt = new AbortController();
168
171
  let made;
169
172
  try {
@@ -183,7 +186,7 @@ export function signInView(options) {
183
186
  /** The first use of a passkey just made. Where it gives nothing (a browser that allows one prompt per press, a closed sheet), one key finishes it. */
184
187
  async function firstOpen(made, pick) {
185
188
  busy(true);
186
- say("Opening your passkey…");
189
+ say(w.openingPasskey);
187
190
  prompt = new AbortController();
188
191
  try {
189
192
  const p = await firstUse(setup(), made, prompt.signal, pick);
@@ -198,7 +201,7 @@ export function signInView(options) {
198
201
  if (f.code !== "cancelled" && f.code !== "failed")
199
202
  return showFault(f);
200
203
  busy(false);
201
- area.replaceChildren(title("Your passkey is made"), h("p", { class: "n133s-text" }, "Press the key to open it."), h("div", { class: "n133s-keys" }, key("Open my passkey", () => void firstOpen(made, true), "primary")));
204
+ area.replaceChildren(title(w.madeTitle), h("p", { class: "n133s-text" }, w.madeText), h("div", { class: "n133s-keys" }, key(w.openMine, () => void firstOpen(made, true), "primary")));
202
205
  say("");
203
206
  focusFirst();
204
207
  }
@@ -210,25 +213,25 @@ export function signInView(options) {
210
213
  const code = h("p", { class: "n133s-code", translate: "no" });
211
214
  const codeBox = h("div", { class: "n133s-codebox" }, code);
212
215
  codeBox.hidden = true;
213
- const go = key("Later", () => resolve(), "quiet");
214
- const copyBtn = key("Copy the code", async () => {
216
+ const go = key(w.later, () => resolve(), "quiet");
217
+ const copyBtn = key(w.copyCode, async () => {
215
218
  try {
216
219
  await navigator.clipboard.writeText(seed);
217
- copyBtn.textContent = "Copied";
220
+ copyBtn.textContent = w.copied;
218
221
  }
219
222
  catch {
220
- copyBtn.textContent = "Copy failed: write it down";
223
+ copyBtn.textContent = w.copyFailed;
221
224
  }
222
225
  });
223
226
  codeBox.append(copyBtn);
224
- const show = key("Show my code", () => {
227
+ const show = key(w.showCode, () => {
225
228
  code.textContent = seed.match(/.{1,16}/g).join("\n");
226
229
  codeBox.hidden = false;
227
230
  show.remove();
228
- go.textContent = "Continue";
231
+ go.textContent = w.continue;
229
232
  go.className = "n133s-btn n133s-primary";
230
233
  }, "primary");
231
- area.replaceChildren(title("Keep a spare key"), h("p", { class: "n133s-text" }, "Your looks belong to this passkey. If you lose it, this code brings them back."), codeBox, h("p", { class: "n133s-fine" }, "Keep it secret: whoever has it can sign in as you. To use it, import it as a seed in a Nano wallet such as Nautilus, then sign in with that wallet."), h("div", { class: "n133s-keys" }, show, go));
234
+ area.replaceChildren(title(w.spareTitle), h("p", { class: "n133s-text" }, w.spareText), codeBox, h("p", { class: "n133s-fine" }, w.spareFine), h("div", { class: "n133s-keys" }, show, go));
232
235
  say("");
233
236
  focusFirst();
234
237
  });
@@ -240,7 +243,7 @@ export function signInView(options) {
240
243
  if (stopped)
241
244
  return;
242
245
  busy(true);
243
- say("Signing in…");
246
+ say(w.signingIn);
244
247
  try {
245
248
  const { signinBody } = await import("@nano133/signin/purse");
246
249
  const turnstile = await personToken();
@@ -251,7 +254,7 @@ export function signInView(options) {
251
254
  }
252
255
  catch (e) {
253
256
  if (!stopped)
254
- showPasskey(e instanceof ApiError ? e.message : "Sign-in did not work. Try again.");
257
+ showPasskey(e instanceof ApiError ? w.refusal(e.message, e.status) : w.signInFailed);
255
258
  }
256
259
  }
257
260
  // ---- a wallet app ----
@@ -261,7 +264,7 @@ export function signInView(options) {
261
264
  stopWaiting();
262
265
  current = null;
263
266
  busy(true);
264
- say("Starting…");
267
+ say(w.starting);
265
268
  try {
266
269
  const turnstile = await personToken();
267
270
  const s = await api.post(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
@@ -275,13 +278,13 @@ export function signInView(options) {
275
278
  catch (e) {
276
279
  busy(false);
277
280
  showWays();
278
- say(e instanceof Error ? e.message : "Sign-in did not start. Try again.");
281
+ say(e instanceof ApiError ? w.refusal(e.message, e.status) : e instanceof Error && e.message ? w.refusal(e.message, 0) : w.startFailed);
279
282
  }
280
283
  }
281
284
  function showWallet(s) {
282
285
  const name = WALLETS[s.way].name;
283
286
  area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
284
- say(`Waiting for ${name}…`);
287
+ say(w.waitingFor(name));
285
288
  focusFirst();
286
289
  }
287
290
  /** What the player does with the wallet: on a touch screen, one key; with a mouse, a QR code for the phone. */
@@ -289,36 +292,36 @@ export function signInView(options) {
289
292
  const open = (text, href, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
290
293
  const qr = (text) => {
291
294
  if (!options.qr)
292
- return h("p", { class: "n133s-fine" }, "Open this page on your phone to sign in with ", name, ".");
295
+ return h("p", { class: "n133s-fine" }, w.openOnPhone(name));
293
296
  const c = options.qr(text);
294
- c.setAttribute("aria-label", `QR code to sign in with ${name}`);
297
+ c.setAttribute("aria-label", w.qrLabel(name));
295
298
  return h("div", { class: "n133s-qr" }, c);
296
299
  };
297
- const only = h("p", { class: "n133s-fine" }, `Only approve a sign-in that you started yourself, on ${location.host}.`);
298
- const comeBack = h("p", { class: "n133s-fine" }, `Approve the sign-in in ${s.way === "berry" ? "your wallet" : name}, then come back to this page.`);
300
+ const only = h("p", { class: "n133s-fine" }, w.onlyYours(location.host));
301
+ const comeBack = h("p", { class: "n133s-fine" }, w.comeBack(s.way === "berry" ? w.yourWallet : name));
299
302
  if (s.way === "nautilus") {
300
- const code = h("p", { class: "n133s-text" }, "Check that Nautilus shows the code ", h("strong", { class: "n133s-signcode" }, s.code ?? ""), ".");
301
- return touch() ? [code, open("Open Nautilus", s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Nautilus on your phone, then approve the sign-in."), code, only];
303
+ const code = h("p", { class: "n133s-text" }, w.checkCode, h("strong", { class: "n133s-signcode" }, s.code ?? ""));
304
+ return touch() ? [code, open(w.openApp(name), s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), code, only];
302
305
  }
303
306
  if (s.way === "nanchat") {
304
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.link, target: "_blank", rel: "noopener" }, "open NanChat"), ".");
305
- return touch() ? [open("Open NanChat", s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with your phone's camera to open NanChat, then approve the sign-in."), only, here];
307
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.link, target: "_blank", rel: "noopener" }, w.openLink(name)));
308
+ return touch() ? [open(w.openApp(name), s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanCamera(name)), only, here];
306
309
  }
307
- const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, "Another wallet");
308
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.walletLink ?? s.link }, "open a wallet"), ".");
309
- return touch() ? [open("Open Berry Wallet", s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Berry Wallet on your phone, then approve the sign-in."), only, here];
310
+ const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, w.anotherWallet);
311
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.walletLink ?? s.link }, w.openLink(w.aWallet)));
312
+ return touch() ? [open(w.openApp(name), s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), only, here];
310
313
  }
311
314
  /** The request ended without a sign-in: say why, and offer a new one. */
312
315
  function again(s, words) {
313
316
  current = null;
314
- area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key("Try again", () => void choose(s.way), "primary"), key("Choose another way", () => showWays())));
317
+ area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key(w.tryAgain, () => void choose(s.way), "primary"), key(w.otherWay, () => showWays())));
315
318
  say(words);
316
319
  }
317
320
  async function poll(s) {
318
321
  if (stopped || current !== s)
319
322
  return;
320
323
  if (Date.now() > s.expiresAt + 5000)
321
- return again(s, "This sign-in expired.");
324
+ return again(s, w.expired);
322
325
  try {
323
326
  const r = await api.post(`${base}/check`, { id: s.id, way: s.way });
324
327
  if (stopped || current !== s)
@@ -326,13 +329,13 @@ export function signInView(options) {
326
329
  if (r.status === "signedin" && r.address)
327
330
  return done({ address: r.address, via: "wallet" });
328
331
  if (r.status === "elsewhere" || r.status === "used")
329
- return again(s, "This sign-in was used in another browser. Start again.");
332
+ return again(s, w.usedElsewhere);
330
333
  if (r.status === "expired")
331
- return again(s, "This sign-in expired.");
334
+ return again(s, w.expired);
332
335
  }
333
336
  catch (e) {
334
337
  if (e instanceof ApiError && e.status === 429)
335
- say("Too many checks. Waiting a moment…");
338
+ say(w.tooManyChecks);
336
339
  }
337
340
  timer = setTimeout(() => void poll(s), 2000);
338
341
  }
@@ -5,4 +5,99 @@ export type FaultWords = {
5
5
  text: string;
6
6
  next: "stay" | "wallets";
7
7
  };
8
- export declare function faultWords(code: PasskeyFault, rpId: string): FaultWords;
8
+ /** A fault's words: its title, and its text (a function when it names the game's domain, `rpId`). */
9
+ export type FaultText = {
10
+ title: string;
11
+ text: string | ((rpId: string) => string);
12
+ };
13
+ export type PlayerSigninWords = {
14
+ /** The sheet's title. The option `title` wins over it. */
15
+ title: string;
16
+ /** The close key's name for a screen reader. */
17
+ close: string;
18
+ /** The first line of the ways. */
19
+ lead: string;
20
+ usePasskey: string;
21
+ /** Under "Use a passkey". */
22
+ passkeyHint: string;
23
+ /** Under "Use a passkey" where the browser reports no passkey PRF. */
24
+ passkeyOff: string;
25
+ lastUsed: string;
26
+ /** Before the wallet tiles, after the passkey. */
27
+ orWallet: string;
28
+ /** Before the wallet tiles, with no passkey before them. */
29
+ chooseWallet: string;
30
+ /** Before the passkey, when it comes last. */
31
+ orPasskey: string;
32
+ back: string;
33
+ passkeyTitle: string;
34
+ passkeyText: string;
35
+ useMine: string;
36
+ makeOne: string;
37
+ waitingPasskey: string;
38
+ makingPasskey: string;
39
+ openingPasskey: string;
40
+ /** A passkey just made, whose first use gave nothing: one key opens it. */
41
+ madeTitle: string;
42
+ madeText: string;
43
+ openMine: string;
44
+ spareTitle: string;
45
+ spareText: string;
46
+ /** Under the code: keep it secret; how to use it. */
47
+ spareFine: string;
48
+ showCode: string;
49
+ copyCode: string;
50
+ copied: string;
51
+ copyFailed: string;
52
+ later: string;
53
+ continue: string;
54
+ signingIn: string;
55
+ signInFailed: string;
56
+ /** After a fault that offers the wallet apps. */
57
+ faultWallets: string;
58
+ faults: Record<PasskeyFault, FaultText>;
59
+ starting: string;
60
+ startFailed: string;
61
+ /** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
62
+ refusal: (text: string, status: number) => string;
63
+ /** The status while the page waits for a wallet ("Berry Wallet"). */
64
+ waitingFor: (wallet: string) => string;
65
+ /** After a sign-in, with the player's short address. */
66
+ signedInAs: (address: string) => string;
67
+ /** The key that opens a wallet app on a touch screen. */
68
+ openApp: (wallet: string) => string;
69
+ anotherWallet: string;
70
+ /** Before a link that opens the wallet on this computer. */
71
+ onComputer: string;
72
+ /** The link's words: open a wallet ("NanChat", or `aWallet`). */
73
+ openLink: (wallet: string) => string;
74
+ aWallet: string;
75
+ /** With no QR hook, on a screen with a mouse. */
76
+ openOnPhone: (wallet: string) => string;
77
+ qrLabel: (wallet: string) => string;
78
+ /** Only approve a sign-in the player started, on this host. */
79
+ onlyYours: (host: string) => string;
80
+ /** After the key that opens the app (`yourWallet` for Berry Wallet, which opens any wallet). */
81
+ comeBack: (wallet: string) => string;
82
+ yourWallet: string;
83
+ /** Under a QR code: scan it with this wallet. */
84
+ scanWith: (wallet: string) => string;
85
+ /** Under NanChat's QR code: the phone's camera opens it. */
86
+ scanCamera: (wallet: string) => string;
87
+ /** Before Nautilus's code (the code follows, bold). */
88
+ checkCode: string;
89
+ tryAgain: string;
90
+ otherWay: string;
91
+ expired: string;
92
+ usedElsewhere: string;
93
+ tooManyChecks: string;
94
+ };
95
+ /** `words` as a game gives it: any of the keys, and any of the faults. */
96
+ export type PlayerSigninWordsOption = Partial<Omit<PlayerSigninWords, "faults">> & {
97
+ faults?: Partial<Record<PasskeyFault, FaultText>>;
98
+ };
99
+ export declare const PLAYER_SIGNIN_WORDS: PlayerSigninWords;
100
+ /** The game's words over the defaults; its faults over the default faults. A key it leaves out, or gives as undefined, keeps its default. */
101
+ export declare function playerSigninWords(over?: PlayerSigninWordsOption): PlayerSigninWords;
102
+ /** A fault's title, text and next step, in the game's words (default: PLAYER_SIGNIN_WORDS). An unknown code gets the words of "failed". */
103
+ export declare function faultWords(code: PasskeyFault, rpId: string, words?: PlayerSigninWordsOption): FaultWords;
@@ -1,26 +1,111 @@
1
- // The sheet's words for each passkey fault, in plain English. The fault codes are @nano133/signin's (PasskeyFault). A fault never names a seed or a
2
- // key, and never tells the player to delete a passkey.
3
- export function faultWords(code, rpId) {
4
- switch (code) {
5
- case "cancelled":
6
- return { title: "No passkey was used", text: "No passkey was used.", next: "stay" };
7
- case "no-prf":
8
- return { title: "Not on this device yet", text: "This device's passkeys cannot sign in to games yet. Use a wallet app, or a passkey on another device.", next: "wallets" };
9
- case "unsupported":
10
- return { title: "No passkeys here", text: "This browser has no passkeys. Use a wallet app.", next: "wallets" };
11
- case "other-form":
12
- return { title: "Not this password manager", text: "Your password manager answered in a way this game cannot read. In the device's sheet, choose its own passkeys (iCloud Keychain or Google Password Manager), or use a wallet app.", next: "wallets" };
13
- case "not-a-purse":
14
- return { title: "Not this game's passkey", text: "That passkey is not one of this game's. Choose another one, or make one.", next: "stay" };
15
- case "wrong-host":
16
- return { title: "Wrong address", text: `Passkeys for this game work only on ${rpId}.`, next: "wallets" };
17
- case "exists":
18
- return { title: "You have a passkey", text: "This device has your passkey already. Use it.", next: "stay" };
19
- case "unstable":
20
- return { title: "Not on this device", text: "This device gave two different answers for one passkey, so it cannot sign in here. Use a wallet app.", next: "wallets" };
21
- case "other-passkey":
22
- return { title: "Another passkey", text: "That is another passkey. Choose the one you just made: its name has today's date.", next: "stay" };
23
- default:
24
- return { title: "That did not work", text: "The passkey did not work. Try again.", next: "stay" };
1
+ // Every word of the sign-in sheet, and its words for each passkey fault, in plain English. A game gives `words` to `mountPlayerSignin` (or to
2
+ // `signInView`) with the keys it wants to change, in its own words or language; the rest stay these. A text with a value in it is a function of that
3
+ // value. The wallets' names (Berry Wallet, NanChat, Nautilus) are names, not words: they stay.
4
+ //
5
+ // The fault codes are @nano133/signin's (PasskeyFault). A fault never names a seed or a key, and never tells the player to delete a passkey. No
6
+ // sentence has more than 10 words.
7
+ export const PLAYER_SIGNIN_WORDS = {
8
+ title: "Sign in",
9
+ close: "Close",
10
+ lead: "No password and no payment.",
11
+ usePasskey: "Use a passkey",
12
+ passkeyHint: "Face ID, Touch ID or your device PIN",
13
+ passkeyOff: "Not on this browser",
14
+ lastUsed: "Last used",
15
+ orWallet: "Or a wallet app",
16
+ chooseWallet: "Choose your wallet",
17
+ orPasskey: "Or a passkey",
18
+ back: "Back",
19
+ passkeyTitle: "Passkey",
20
+ passkeyText: "Face ID, Touch ID or your device PIN. The passkey stays on your device. The game never sees it.",
21
+ useMine: "Use my passkey",
22
+ makeOne: "Make a passkey",
23
+ waitingPasskey: "Waiting for your passkey…",
24
+ makingPasskey: "Making your passkey…",
25
+ openingPasskey: "Opening your passkey…",
26
+ madeTitle: "Your passkey is made",
27
+ madeText: "Press the key to open it.",
28
+ openMine: "Open my passkey",
29
+ spareTitle: "Keep a spare key",
30
+ spareText: "Your looks belong to this passkey. If you lose it, this code brings them back.",
31
+ spareFine: "Keep it secret. Anyone with it can sign in as you. Import it as a seed in Nautilus or another wallet. Then sign in with that wallet.",
32
+ showCode: "Show my code",
33
+ copyCode: "Copy the code",
34
+ copied: "Copied",
35
+ copyFailed: "Copy failed: write it down",
36
+ later: "Later",
37
+ continue: "Continue",
38
+ signingIn: "Signing in…",
39
+ signInFailed: "Sign-in did not work. Try again.",
40
+ faultWallets: "Sign in with a wallet app",
41
+ faults: {
42
+ cancelled: { title: "No passkey was used", text: "No passkey was used." },
43
+ "no-prf": { title: "Not on this device yet", text: "This device's passkeys cannot sign in to games yet. Use a wallet app, or a passkey on another device." },
44
+ unsupported: { title: "No passkeys here", text: "This browser has no passkeys. Use a wallet app." },
45
+ "other-form": { title: "Not this password manager", text: "This game cannot read your password manager's answer. Choose iCloud Keychain or Google Password Manager instead. Or use a wallet app." },
46
+ "not-a-purse": { title: "Not this game's passkey", text: "That passkey is not one of this game's. Choose another one, or make one." },
47
+ "wrong-host": { title: "Wrong address", text: (rpId) => `Passkeys for this game work only on ${rpId}.` },
48
+ exists: { title: "You have a passkey", text: "This device has your passkey already. Use it." },
49
+ unstable: { title: "Not on this device", text: "This device gave two answers for one passkey. It cannot sign in here. Use a wallet app." },
50
+ "other-passkey": { title: "Another passkey", text: "That is another passkey. Choose the one you just made. Its name has today's date." },
51
+ failed: { title: "That did not work", text: "The passkey did not work. Try again." },
52
+ },
53
+ starting: "Starting…",
54
+ startFailed: "Sign-in did not start. Try again.",
55
+ refusal: (text) => text,
56
+ waitingFor: (wallet) => `Waiting for ${wallet}…`,
57
+ signedInAs: (address) => `Signed in as ${address}`,
58
+ openApp: (wallet) => `Open ${wallet}`,
59
+ anotherWallet: "Another wallet",
60
+ onComputer: "On this computer: ",
61
+ openLink: (wallet) => `open ${wallet}`,
62
+ aWallet: "a wallet",
63
+ openOnPhone: (wallet) => `Open this page on your phone to use ${wallet}.`,
64
+ qrLabel: (wallet) => `QR code to sign in with ${wallet}`,
65
+ onlyYours: (host) => `Only approve a sign-in you started yourself, on ${host}.`,
66
+ comeBack: (wallet) => `Approve the sign-in in ${wallet}. Then come back to this page.`,
67
+ yourWallet: "your wallet",
68
+ scanWith: (wallet) => `Scan this with ${wallet} on your phone. Then approve the sign-in.`,
69
+ scanCamera: (wallet) => `Scan this with your phone's camera to open ${wallet}. Then approve the sign-in.`,
70
+ checkCode: "Check that Nautilus shows this code: ",
71
+ tryAgain: "Try again",
72
+ otherWay: "Choose another way",
73
+ expired: "This sign-in expired.",
74
+ usedElsewhere: "This sign-in was used in another browser. Start again.",
75
+ tooManyChecks: "Too many checks. Waiting a moment…",
76
+ };
77
+ /** The game's words over the defaults; its faults over the default faults. A key it leaves out, or gives as undefined, keeps its default. */
78
+ export function playerSigninWords(over) {
79
+ const out = { ...PLAYER_SIGNIN_WORDS, faults: { ...PLAYER_SIGNIN_WORDS.faults } };
80
+ for (const [k, v] of Object.entries(over ?? {})) {
81
+ if (v === undefined || !(k in PLAYER_SIGNIN_WORDS))
82
+ continue;
83
+ if (k === "faults") {
84
+ for (const [code, f] of Object.entries(v))
85
+ if (f && code in out.faults)
86
+ out.faults[code] = f;
87
+ }
88
+ else
89
+ out[k] = v;
25
90
  }
91
+ return out;
92
+ }
93
+ /** What happens after each fault: the passkey step again, or the wallet apps. Not words: the sheet's steps. */
94
+ const NEXT = {
95
+ cancelled: "stay",
96
+ "no-prf": "wallets",
97
+ unsupported: "wallets",
98
+ "other-form": "wallets",
99
+ "not-a-purse": "stay",
100
+ "wrong-host": "wallets",
101
+ exists: "stay",
102
+ unstable: "wallets",
103
+ "other-passkey": "stay",
104
+ failed: "stay",
105
+ };
106
+ /** A fault's title, text and next step, in the game's words (default: PLAYER_SIGNIN_WORDS). An unknown code gets the words of "failed". */
107
+ export function faultWords(code, rpId, words = PLAYER_SIGNIN_WORDS) {
108
+ const known = code in NEXT ? code : "failed";
109
+ const f = words.faults?.[known] ?? PLAYER_SIGNIN_WORDS.faults[known];
110
+ return { title: f.title, text: typeof f.text === "function" ? f.text(rpId) : f.text, next: NEXT[known] };
26
111
  }
@@ -14,7 +14,7 @@
14
14
  // TEST MODE: with no payee (none, the example address, or a wrong checksum) or `testMode: true`, the answer carries NO address and NO link, and the page
15
15
  // shows "TEST MODE: DO NOT PAY". A payment to an address nobody holds the key for is lost for good.
16
16
  import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
17
- import { checkLooks } from "../looks/look.js";
17
+ import { checkLooks, usdDecimals } from "../looks/look.js";
18
18
  import { payeeOf } from "./address.js";
19
19
  import { nanoUri, priceRaw, xnoText } from "./amount.js";
20
20
  import { PUBLIC_NODES } from "./nodes.js";
@@ -101,7 +101,7 @@ export function createShopHandler(options) {
101
101
  if (!price)
102
102
  return fail(503, "Prices are not available now. Try again later.");
103
103
  try {
104
- const order = await reserveOrder(store, { player, lookId: look.id, usd: look.priceUsd.toFixed(2), rate: rate.usd, price, visitor, now: at });
104
+ const order = await reserveOrder(store, { player, lookId: look.id, usd: usdDecimals(look.priceUsd), rate: rate.usd, price, visitor, now: at });
105
105
  return json(payView(order, at), 201);
106
106
  }
107
107
  catch (e) {
@@ -5,3 +5,5 @@ export type { LooksViewOptions, SlotWords } from "./looks.js";
5
5
  export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
6
6
  export type { PayOptions, PayPanel } from "./pay.js";
7
7
  export { qrModules, qrSvg, qrVersionFor } from "./qr.js";
8
+ export { COINGECKO_CREDIT, SHOP_WORDS, priceLine, shopWords } from "./words.js";
9
+ export type { ShopWords } from "./words.js";
@@ -4,8 +4,11 @@
4
4
  // import "@nano133/nanoterra/shop/style.css";
5
5
  // const shop = mountShop(document.getElementById("shop")!, { looks, owned: () => me.owned, signedIn: () => !!me.address, signIn, worn, wear, onOwned });
6
6
  //
7
+ // Every word of the two sheets can be the game's own: `mountShop(el, { ..., words: { sendExactly: "Pay exactly" } })` (ShopWords; SHOP_WORDS are the defaults).
8
+ //
7
9
  // The server part is "@nano133/nanoterra/shop" (the handler and the stores): it must not load this one.
8
10
  export { mountShop } from "./mount.js";
9
11
  export { looksView, slotsOf } from "./looks.js";
10
12
  export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
11
13
  export { qrModules, qrSvg, qrVersionFor } from "./qr.js";
14
+ export { COINGECKO_CREDIT, SHOP_WORDS, priceLine, shopWords } from "./words.js";
@@ -1,4 +1,5 @@
1
1
  import { type Look } from "../../looks/look.js";
2
+ import { type ShopWords } from "./words.js";
2
3
  /** A tab's words: its key's word, and its plain choice's name (an item slot's: "Plain shovel", "No charm"). */
3
4
  export type SlotWords = {
4
5
  tab: string;
@@ -24,6 +25,8 @@ export type LooksViewOptions = {
24
25
  tab?: string;
25
26
  /** A tab was chosen: the sheet draws again with it. */
26
27
  onTab?: (slot: string) => void;
28
+ /** The game's words (./words.ts): the keys it leaves out keep the defaults. */
29
+ words?: Partial<ShopWords>;
27
30
  };
28
31
  /** The wear slots of a list, in the order they first show (the outfits first). */
29
32
  export declare function slotsOf(looks: readonly Look[]): string[];
@@ -5,8 +5,7 @@
5
5
  // with `takeOff`, each item tab starts with its plain choice (free, "No charm").
6
6
  import { h } from "../../admin/panel/dom.js";
7
7
  import { OUTFIT, isOutfit, usdText, wearOf } from "../../looks/look.js";
8
- const HAT_WORDS = { cap: "with a cap", beanie: "with a beanie", crown: "with a crown", tophat: "with a top hat" };
9
- const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, " ");
8
+ import { shopWords } from "./words.js";
10
9
  /** The wear slots of a list, in the order they first show (the outfits first). */
11
10
  export function slotsOf(looks) {
12
11
  const out = [OUTFIT];
@@ -34,13 +33,14 @@ function swatchOf(look) {
34
33
  export function looksView(p) {
35
34
  const slots = slotsOf(p.looks);
36
35
  const tab = p.tab && slots.includes(p.tab) ? p.tab : slots[0];
37
- const words = (slot) => p.slots?.[slot] ?? (slot === OUTFIT ? { tab: "Outfits" } : { tab: titleCase(slot), plain: `No ${slot.replace(/-/g, " ")}` });
36
+ const w = shopWords(p.words);
37
+ const words = (slot) => p.slots?.[slot] ?? (slot === OUTFIT ? { tab: w.outfits } : { tab: w.slotTab(slot), plain: w.slotPlain(slot) });
38
38
  const list = h("ul", { class: "n133s-looks" });
39
39
  const row = (pic, name, line, action, worn) => h("li", { class: `n133s-look${worn ? " n133s-look-worn" : ""}` }, pic, h("span", { class: "n133s-look-text" }, h("strong", {}, name), h("small", {}, line)), action);
40
40
  if (tab !== OUTFIT && p.takeOff) {
41
41
  const plain = p.plainWorn ? p.plainWorn(tab) : !p.looks.some((l) => wearOf(l) === tab && p.worn(l));
42
- const action = h("button", { class: `n133s-btn${plain ? "" : " n133s-primary"}`, type: "button", disabled: plain, onclick: () => p.takeOff(tab) }, plain ? "Wearing" : "Wear");
43
- list.append(row(h("span", { class: "n133s-swatch n133s-swatch-plain", "aria-hidden": "true" }), words(tab).plain ?? `No ${tab}`, "free", action, plain));
42
+ const action = h("button", { class: `n133s-btn${plain ? "" : " n133s-primary"}`, type: "button", disabled: plain, onclick: () => p.takeOff(tab) }, plain ? w.wearing : w.wear);
43
+ list.append(row(h("span", { class: "n133s-swatch n133s-swatch-plain", "aria-hidden": "true" }), words(tab).plain ?? w.slotPlain(tab), w.free, action, plain));
44
44
  }
45
45
  for (const look of p.looks) {
46
46
  if (wearOf(look) !== tab)
@@ -48,14 +48,14 @@ export function looksView(p) {
48
48
  const mine = look.priceUsd === 0 || p.owned.has(look.id);
49
49
  const worn = p.worn(look);
50
50
  const action = mine
51
- ? h("button", { class: `n133s-btn${worn ? "" : " n133s-primary"}`, type: "button", disabled: worn, onclick: () => p.wear(look) }, worn ? "Wearing" : "Wear")
52
- : h("button", { class: "n133s-btn n133s-buy", type: "button", onclick: () => p.buy(look) }, `Buy ${usdText(look.priceUsd)}`);
53
- const what = isOutfit(look) ? (look.change.hat ? HAT_WORDS[look.change.hat] : "colour only") : tab.replace(/-/g, " ");
54
- const state = mine ? (look.priceUsd === 0 ? " · free" : " · yours") : "";
51
+ ? h("button", { class: `n133s-btn${worn ? "" : " n133s-primary"}`, type: "button", disabled: worn, onclick: () => p.wear(look) }, worn ? w.wearing : w.wear)
52
+ : h("button", { class: "n133s-btn n133s-buy", type: "button", onclick: () => p.buy(look) }, w.buy(usdText(look.priceUsd)));
53
+ const what = isOutfit(look) ? w.outfitLine(look.change.hat ?? null) : w.itemLine(tab);
54
+ const state = mine ? ` · ${look.priceUsd === 0 ? w.free : w.yours}` : "";
55
55
  list.append(row(p.picture?.(look) ?? swatchOf(look), look.name, `${what}${state}`, action, worn));
56
56
  }
57
57
  const tabs = slots.length > 1
58
- ? h("div", { class: "n133s-tabs", role: "tablist", "aria-label": "Kinds of looks" }, ...slots.map((s) => h("button", { class: "n133s-tab", type: "button", role: "tab", "aria-selected": s === tab ? "true" : "false", onclick: () => p.onTab?.(s) }, words(s).tab)))
58
+ ? h("div", { class: "n133s-tabs", role: "tablist", "aria-label": w.kinds }, ...slots.map((s) => h("button", { class: "n133s-tab", type: "button", role: "tab", "aria-selected": s === tab ? "true" : "false", onclick: () => p.onTab?.(s) }, words(s).tab)))
59
59
  : null;
60
- return h("div", { class: "n133s-looks-view" }, tabs, list, h("p", { class: "n133s-fine" }, "A look changes how your character looks. It never changes a score. You pay in Nano (XNO), straight to the game's creator."), p.signedIn ? null : h("p", { class: "n133s-fine" }, "To buy a look, sign in with Nano first: your looks belong to your Nano address."));
60
+ return h("div", { class: "n133s-looks-view" }, tabs, list, h("p", { class: "n133s-fine" }, w.looksNote), p.signedIn ? null : h("p", { class: "n133s-fine" }, w.signInFirst));
61
61
  }