@nano133/nanoterra 0.9.0 → 0.9.3
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.
- package/CHANGELOG.md +40 -0
- package/README.md +55 -7
- package/ai/SPEC.md +6 -0
- package/dist/player-signin/panel/index.d.ts +2 -2
- package/dist/player-signin/panel/index.js +3 -2
- package/dist/player-signin/panel/mount.d.ts +1 -1
- package/dist/player-signin/panel/mount.js +5 -3
- package/dist/player-signin/panel/view.d.ts +3 -0
- package/dist/player-signin/panel/view.js +49 -46
- package/dist/player-signin/panel/words.d.ts +96 -1
- package/dist/player-signin/panel/words.js +109 -24
- package/dist/shop/panel/index.d.ts +2 -0
- package/dist/shop/panel/index.js +3 -0
- package/dist/shop/panel/looks.d.ts +3 -0
- package/dist/shop/panel/looks.js +11 -11
- package/dist/shop/panel/mount.d.ts +18 -2
- package/dist/shop/panel/mount.js +48 -21
- package/dist/shop/panel/pay.d.ts +3 -0
- package/dist/shop/panel/pay.js +38 -25
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/package.json +1 -1
- package/src/player-signin/panel/index.ts +4 -3
- package/src/player-signin/panel/mount.ts +6 -4
- package/src/player-signin/panel/style.css +22 -17
- package/src/player-signin/panel/view.ts +60 -55
- package/src/player-signin/panel/words.ts +202 -24
- package/src/shop/panel/index.ts +4 -0
- package/src/shop/panel/looks.ts +14 -14
- package/src/shop/panel/mount.ts +60 -21
- package/src/shop/panel/pay.ts +51 -30
- package/src/shop/panel/style.css +24 -20
- 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
|
|
|
15
17
|
import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport, type NewPasskey, type PasskeyScheme, type PasskeySetup } from "@nano133/signin/passkey";
|
|
16
18
|
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
@@ -20,7 +22,7 @@ import { checkScheme } from "../scheme-check.js";
|
|
|
20
22
|
import { previewHost } from "../host.js";
|
|
21
23
|
import { WAYS, type Way } from "../types.js";
|
|
22
24
|
import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
|
|
23
|
-
import { faultWords } from "./words.js";
|
|
25
|
+
import { faultWords, playerSigninWords, type PlayerSigninWordsOption } from "./words.js";
|
|
24
26
|
|
|
25
27
|
export type SignedIn = { address: string; via: "wallet" | "passkey" };
|
|
26
28
|
|
|
@@ -50,6 +52,8 @@ export type PlayerSigninViewOptions = {
|
|
|
50
52
|
fetch?: typeof fetch;
|
|
51
53
|
/** A touch screen (one key that opens the app) or not (a QR code). Default: `(pointer: coarse)`. */
|
|
52
54
|
touch?: boolean;
|
|
55
|
+
/** The game's words for the sheet, over the defaults (PLAYER_SIGNIN_WORDS): `{ lead: "No password.", faults: { cancelled: { ... } } }`. */
|
|
56
|
+
words?: PlayerSigninWordsOption;
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
export type PlayerSigninView = {
|
|
@@ -102,6 +106,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
102
106
|
const passkeyOn = options.passkey ?? !previewHost(rpId);
|
|
103
107
|
const scheme = passkeyOn ? checkScheme(options.scheme as PasskeyScheme) : null;
|
|
104
108
|
const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
109
|
+
const w = playerSigninWords(options.words);
|
|
105
110
|
|
|
106
111
|
let stopped = false;
|
|
107
112
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -122,7 +127,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
122
127
|
node.toggleAttribute("aria-busy", on);
|
|
123
128
|
};
|
|
124
129
|
const key = (text: string, onclick: () => void, kind: "primary" | "plain" | "quiet" = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
|
|
125
|
-
const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {},
|
|
130
|
+
const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, w.back));
|
|
126
131
|
const title = (text: string) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
|
|
127
132
|
const focusFirst = () => (area.querySelector<HTMLElement>("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
|
|
128
133
|
const setup = (): PasskeySetup => ({ rpId, rpName: options.name, scheme: scheme!, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
|
|
@@ -140,7 +145,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
140
145
|
function done(player: SignedIn) {
|
|
141
146
|
stopWaiting();
|
|
142
147
|
current = null;
|
|
143
|
-
area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {},
|
|
148
|
+
area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, w.signedInAs(shortAddress(player.address)))));
|
|
144
149
|
say("");
|
|
145
150
|
options.onSignedIn?.(player);
|
|
146
151
|
}
|
|
@@ -154,8 +159,8 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
154
159
|
"button",
|
|
155
160
|
{ class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() },
|
|
156
161
|
h("span", { class: "n133s-pk-mark" }, keyIcon()),
|
|
157
|
-
h("span", { class: "n133s-pk-words" }, h("strong", {},
|
|
158
|
-
last ? h("span", { class: "n133s-last" },
|
|
162
|
+
h("span", { class: "n133s-pk-words" }, h("strong", {}, w.usePasskey), h("small", {}, off ? w.passkeyOff : w.passkeyHint)),
|
|
163
|
+
last ? h("span", { class: "n133s-last" }, w.lastUsed) : null,
|
|
159
164
|
);
|
|
160
165
|
}
|
|
161
166
|
|
|
@@ -164,13 +169,13 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
164
169
|
return h(
|
|
165
170
|
"div",
|
|
166
171
|
{ class: "n133s-tiles" },
|
|
167
|
-
...ways.map((
|
|
172
|
+
...ways.map((way) =>
|
|
168
173
|
h(
|
|
169
174
|
"button",
|
|
170
|
-
{ class: "n133s-tile", type: "button", onclick: () => choose(
|
|
171
|
-
h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[
|
|
172
|
-
h("span", { class: "n133s-name" }, WALLETS[
|
|
173
|
-
last ===
|
|
175
|
+
{ class: "n133s-tile", type: "button", onclick: () => choose(way) },
|
|
176
|
+
h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[way].mark),
|
|
177
|
+
h("span", { class: "n133s-name" }, WALLETS[way].name),
|
|
178
|
+
last === way ? h("span", { class: "n133s-last" }, w.lastUsed) : null,
|
|
174
179
|
),
|
|
175
180
|
),
|
|
176
181
|
);
|
|
@@ -181,8 +186,8 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
181
186
|
busy(false);
|
|
182
187
|
const pk = passkeyOn ? passkeyTile() : null;
|
|
183
188
|
const first = pk && support !== "no";
|
|
184
|
-
const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ?
|
|
185
|
-
fill(h("p", { class: "n133s-lead" },
|
|
189
|
+
const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? w.orWallet : w.chooseWallet), walletTiles()] : [];
|
|
190
|
+
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);
|
|
186
191
|
say("");
|
|
187
192
|
}
|
|
188
193
|
|
|
@@ -191,13 +196,13 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
191
196
|
function showFault(e: unknown): void {
|
|
192
197
|
busy(false);
|
|
193
198
|
const f = passkeyFault(e);
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
199
|
+
const fw = faultWords(f.code, rpId, w);
|
|
200
|
+
if (fw.next === "stay") return showPasskey(fw.text);
|
|
196
201
|
fill(
|
|
197
202
|
backKey(),
|
|
198
|
-
title(
|
|
199
|
-
h("p", { class: "n133s-text n133s-fault", "data-fault": f.code },
|
|
200
|
-
ways.length ? h("p", { class: "n133s-or" },
|
|
203
|
+
title(fw.title),
|
|
204
|
+
h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, fw.text),
|
|
205
|
+
ways.length ? h("p", { class: "n133s-or" }, w.faultWallets) : null,
|
|
201
206
|
ways.length ? walletTiles() : null,
|
|
202
207
|
);
|
|
203
208
|
say("");
|
|
@@ -212,12 +217,12 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
212
217
|
if (support === "no") return showFault(new PasskeyError("no-prf"));
|
|
213
218
|
if (!hostFits(location.hostname, rpId)) return showFault({ name: "SecurityError" });
|
|
214
219
|
const known = readMemo(rpId).ids.length > 0;
|
|
215
|
-
const use = key(
|
|
216
|
-
const make = key(
|
|
220
|
+
const use = key(w.useMine, () => void usePasskey(), known ? "primary" : "plain");
|
|
221
|
+
const make = key(w.makeOne, () => void makeNew(), known ? "plain" : "primary");
|
|
217
222
|
area.replaceChildren(
|
|
218
223
|
backKey(),
|
|
219
|
-
title(
|
|
220
|
-
h("p", { class: "n133s-text" },
|
|
224
|
+
title(w.passkeyTitle),
|
|
225
|
+
h("p", { class: "n133s-text" }, w.passkeyText),
|
|
221
226
|
h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use),
|
|
222
227
|
);
|
|
223
228
|
say(note ?? "");
|
|
@@ -226,7 +231,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
226
231
|
|
|
227
232
|
async function usePasskey() {
|
|
228
233
|
busy(true);
|
|
229
|
-
say(
|
|
234
|
+
say(w.waitingPasskey);
|
|
230
235
|
prompt = new AbortController();
|
|
231
236
|
try {
|
|
232
237
|
const p = await openPasskey(setup(), prompt.signal);
|
|
@@ -239,7 +244,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
239
244
|
|
|
240
245
|
async function makeNew() {
|
|
241
246
|
busy(true);
|
|
242
|
-
say(
|
|
247
|
+
say(w.makingPasskey);
|
|
243
248
|
prompt = new AbortController();
|
|
244
249
|
let made: NewPasskey;
|
|
245
250
|
try {
|
|
@@ -257,7 +262,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
257
262
|
/** 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. */
|
|
258
263
|
async function firstOpen(made: NewPasskey, pick: boolean) {
|
|
259
264
|
busy(true);
|
|
260
|
-
say(
|
|
265
|
+
say(w.openingPasskey);
|
|
261
266
|
prompt = new AbortController();
|
|
262
267
|
try {
|
|
263
268
|
const p = await firstUse(setup(), made, prompt.signal, pick);
|
|
@@ -268,7 +273,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
268
273
|
const f = passkeyFault(e);
|
|
269
274
|
if (f.code !== "cancelled" && f.code !== "failed") return showFault(f);
|
|
270
275
|
busy(false);
|
|
271
|
-
area.replaceChildren(title(
|
|
276
|
+
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")));
|
|
272
277
|
say("");
|
|
273
278
|
focusFirst();
|
|
274
279
|
}
|
|
@@ -281,28 +286,28 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
281
286
|
const code = h("p", { class: "n133s-code", translate: "no" });
|
|
282
287
|
const codeBox = h("div", { class: "n133s-codebox" }, code);
|
|
283
288
|
codeBox.hidden = true;
|
|
284
|
-
const go = key(
|
|
285
|
-
const copyBtn = key(
|
|
289
|
+
const go = key(w.later, () => resolve(), "quiet");
|
|
290
|
+
const copyBtn = key(w.copyCode, async () => {
|
|
286
291
|
try {
|
|
287
292
|
await navigator.clipboard.writeText(seed);
|
|
288
|
-
copyBtn.textContent =
|
|
293
|
+
copyBtn.textContent = w.copied;
|
|
289
294
|
} catch {
|
|
290
|
-
copyBtn.textContent =
|
|
295
|
+
copyBtn.textContent = w.copyFailed;
|
|
291
296
|
}
|
|
292
297
|
});
|
|
293
298
|
codeBox.append(copyBtn);
|
|
294
|
-
const show = key(
|
|
299
|
+
const show = key(w.showCode, () => {
|
|
295
300
|
code.textContent = seed.match(/.{1,16}/g)!.join("\n");
|
|
296
301
|
codeBox.hidden = false;
|
|
297
302
|
show.remove();
|
|
298
|
-
go.textContent =
|
|
303
|
+
go.textContent = w.continue;
|
|
299
304
|
go.className = "n133s-btn n133s-primary";
|
|
300
305
|
}, "primary");
|
|
301
306
|
area.replaceChildren(
|
|
302
|
-
title(
|
|
303
|
-
h("p", { class: "n133s-text" },
|
|
307
|
+
title(w.spareTitle),
|
|
308
|
+
h("p", { class: "n133s-text" }, w.spareText),
|
|
304
309
|
codeBox,
|
|
305
|
-
h("p", { class: "n133s-fine" },
|
|
310
|
+
h("p", { class: "n133s-fine" }, w.spareFine),
|
|
306
311
|
h("div", { class: "n133s-keys" }, show, go),
|
|
307
312
|
);
|
|
308
313
|
say("");
|
|
@@ -315,7 +320,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
315
320
|
if (fresh && options.spareKey !== false) await spareKey(seed);
|
|
316
321
|
if (stopped) return;
|
|
317
322
|
busy(true);
|
|
318
|
-
say(
|
|
323
|
+
say(w.signingIn);
|
|
319
324
|
try {
|
|
320
325
|
const { signinBody } = await import("@nano133/signin/purse");
|
|
321
326
|
const turnstile = await personToken();
|
|
@@ -323,7 +328,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
323
328
|
rememberOpened(rpId, credentialId);
|
|
324
329
|
if (!stopped) done({ address: r.address, via: "passkey" });
|
|
325
330
|
} catch (e) {
|
|
326
|
-
if (!stopped) showPasskey(e instanceof ApiError ? e.message
|
|
331
|
+
if (!stopped) showPasskey(e instanceof ApiError ? w.refusal(e.message, e.status) : w.signInFailed);
|
|
327
332
|
}
|
|
328
333
|
}
|
|
329
334
|
|
|
@@ -334,7 +339,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
334
339
|
stopWaiting();
|
|
335
340
|
current = null;
|
|
336
341
|
busy(true);
|
|
337
|
-
say(
|
|
342
|
+
say(w.starting);
|
|
338
343
|
try {
|
|
339
344
|
const turnstile = await personToken();
|
|
340
345
|
const s = await api.post<Start>(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
|
|
@@ -346,14 +351,14 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
346
351
|
} catch (e) {
|
|
347
352
|
busy(false);
|
|
348
353
|
showWays();
|
|
349
|
-
say(e instanceof
|
|
354
|
+
say(e instanceof ApiError ? w.refusal(e.message, e.status) : e instanceof Error && e.message ? w.refusal(e.message, 0) : w.startFailed);
|
|
350
355
|
}
|
|
351
356
|
}
|
|
352
357
|
|
|
353
358
|
function showWallet(s: Start) {
|
|
354
359
|
const name = WALLETS[s.way].name;
|
|
355
360
|
area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
|
|
356
|
-
say(
|
|
361
|
+
say(w.waitingFor(name));
|
|
357
362
|
focusFirst();
|
|
358
363
|
}
|
|
359
364
|
|
|
@@ -361,44 +366,44 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
|
|
|
361
366
|
function walletSteps(s: Start, name: string): Node[] {
|
|
362
367
|
const open = (text: string, href: string, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
|
|
363
368
|
const qr = (text: string) => {
|
|
364
|
-
if (!options.qr) return h("p", { class: "n133s-fine" },
|
|
369
|
+
if (!options.qr) return h("p", { class: "n133s-fine" }, w.openOnPhone(name));
|
|
365
370
|
const c = options.qr(text);
|
|
366
|
-
c.setAttribute("aria-label",
|
|
371
|
+
c.setAttribute("aria-label", w.qrLabel(name));
|
|
367
372
|
return h("div", { class: "n133s-qr" }, c);
|
|
368
373
|
};
|
|
369
|
-
const only = h("p", { class: "n133s-fine" },
|
|
370
|
-
const comeBack = h("p", { class: "n133s-fine" },
|
|
374
|
+
const only = h("p", { class: "n133s-fine" }, w.onlyYours(location.host));
|
|
375
|
+
const comeBack = h("p", { class: "n133s-fine" }, w.comeBack(s.way === "berry" ? w.yourWallet : name));
|
|
371
376
|
if (s.way === "nautilus") {
|
|
372
|
-
const code = h("p", { class: "n133s-text" },
|
|
373
|
-
return touch() ? [code, open(
|
|
377
|
+
const code = h("p", { class: "n133s-text" }, w.checkCode, h("strong", { class: "n133s-signcode" }, s.code ?? ""));
|
|
378
|
+
return touch() ? [code, open(w.openApp(name), s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), code, only];
|
|
374
379
|
}
|
|
375
380
|
if (s.way === "nanchat") {
|
|
376
|
-
const here = h("p", { class: "n133s-fine" },
|
|
377
|
-
return touch() ? [open(
|
|
381
|
+
const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.link, target: "_blank", rel: "noopener" }, w.openLink(name)));
|
|
382
|
+
return touch() ? [open(w.openApp(name), s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanCamera(name)), only, here];
|
|
378
383
|
}
|
|
379
|
-
const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link },
|
|
380
|
-
const here = h("p", { class: "n133s-fine" },
|
|
381
|
-
return touch() ? [open(
|
|
384
|
+
const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, w.anotherWallet);
|
|
385
|
+
const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.walletLink ?? s.link }, w.openLink(w.aWallet)));
|
|
386
|
+
return touch() ? [open(w.openApp(name), s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), only, here];
|
|
382
387
|
}
|
|
383
388
|
|
|
384
389
|
/** The request ended without a sign-in: say why, and offer a new one. */
|
|
385
390
|
function again(s: Start, words: string) {
|
|
386
391
|
current = null;
|
|
387
|
-
area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key(
|
|
392
|
+
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())));
|
|
388
393
|
say(words);
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
async function poll(s: Start) {
|
|
392
397
|
if (stopped || current !== s) return;
|
|
393
|
-
if (Date.now() > s.expiresAt + 5000) return again(s,
|
|
398
|
+
if (Date.now() > s.expiresAt + 5000) return again(s, w.expired);
|
|
394
399
|
try {
|
|
395
400
|
const r = await api.post<{ status: string; address?: string }>(`${base}/check`, { id: s.id, way: s.way });
|
|
396
401
|
if (stopped || current !== s) return;
|
|
397
402
|
if (r.status === "signedin" && r.address) return done({ address: r.address, via: "wallet" });
|
|
398
|
-
if (r.status === "elsewhere" || r.status === "used") return again(s,
|
|
399
|
-
if (r.status === "expired") return again(s,
|
|
403
|
+
if (r.status === "elsewhere" || r.status === "used") return again(s, w.usedElsewhere);
|
|
404
|
+
if (r.status === "expired") return again(s, w.expired);
|
|
400
405
|
} catch (e) {
|
|
401
|
-
if (e instanceof ApiError && e.status === 429) say(
|
|
406
|
+
if (e instanceof ApiError && e.status === 429) say(w.tooManyChecks);
|
|
402
407
|
}
|
|
403
408
|
timer = setTimeout(() => void poll(s), 2000);
|
|
404
409
|
}
|
|
@@ -1,32 +1,210 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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.
|
|
3
7
|
|
|
4
8
|
import type { PasskeyFault } from "@nano133/signin/passkey";
|
|
5
9
|
|
|
6
10
|
/** What to do next after a fault: stay on the passkey step, or offer the wallet apps. */
|
|
7
11
|
export type FaultWords = { title: string; text: string; next: "stay" | "wallets" };
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
13
|
+
/** A fault's words: its title, and its text (a function when it names the game's domain, `rpId`). */
|
|
14
|
+
export type FaultText = { title: string; text: string | ((rpId: string) => string) };
|
|
15
|
+
|
|
16
|
+
export type PlayerSigninWords = {
|
|
17
|
+
/** The sheet's title. The option `title` wins over it. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** The close key's name for a screen reader. */
|
|
20
|
+
close: string;
|
|
21
|
+
/** The first line of the ways. */
|
|
22
|
+
lead: string;
|
|
23
|
+
usePasskey: string;
|
|
24
|
+
/** Under "Use a passkey". */
|
|
25
|
+
passkeyHint: string;
|
|
26
|
+
/** Under "Use a passkey" where the browser reports no passkey PRF. */
|
|
27
|
+
passkeyOff: string;
|
|
28
|
+
lastUsed: string;
|
|
29
|
+
/** Before the wallet tiles, after the passkey. */
|
|
30
|
+
orWallet: string;
|
|
31
|
+
/** Before the wallet tiles, with no passkey before them. */
|
|
32
|
+
chooseWallet: string;
|
|
33
|
+
/** Before the passkey, when it comes last. */
|
|
34
|
+
orPasskey: string;
|
|
35
|
+
back: string;
|
|
36
|
+
|
|
37
|
+
// ---- the passkey ----
|
|
38
|
+
passkeyTitle: string;
|
|
39
|
+
passkeyText: string;
|
|
40
|
+
useMine: string;
|
|
41
|
+
makeOne: string;
|
|
42
|
+
waitingPasskey: string;
|
|
43
|
+
makingPasskey: string;
|
|
44
|
+
openingPasskey: string;
|
|
45
|
+
/** A passkey just made, whose first use gave nothing: one key opens it. */
|
|
46
|
+
madeTitle: string;
|
|
47
|
+
madeText: string;
|
|
48
|
+
openMine: string;
|
|
49
|
+
spareTitle: string;
|
|
50
|
+
spareText: string;
|
|
51
|
+
/** Under the code: keep it secret; how to use it. */
|
|
52
|
+
spareFine: string;
|
|
53
|
+
showCode: string;
|
|
54
|
+
copyCode: string;
|
|
55
|
+
copied: string;
|
|
56
|
+
copyFailed: string;
|
|
57
|
+
later: string;
|
|
58
|
+
continue: string;
|
|
59
|
+
signingIn: string;
|
|
60
|
+
signInFailed: string;
|
|
61
|
+
/** After a fault that offers the wallet apps. */
|
|
62
|
+
faultWallets: string;
|
|
63
|
+
faults: Record<PasskeyFault, FaultText>;
|
|
64
|
+
|
|
65
|
+
// ---- a wallet app ----
|
|
66
|
+
starting: string;
|
|
67
|
+
startFailed: string;
|
|
68
|
+
/** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
|
|
69
|
+
refusal: (text: string, status: number) => string;
|
|
70
|
+
/** The status while the page waits for a wallet ("Berry Wallet"). */
|
|
71
|
+
waitingFor: (wallet: string) => string;
|
|
72
|
+
/** After a sign-in, with the player's short address. */
|
|
73
|
+
signedInAs: (address: string) => string;
|
|
74
|
+
/** The key that opens a wallet app on a touch screen. */
|
|
75
|
+
openApp: (wallet: string) => string;
|
|
76
|
+
anotherWallet: string;
|
|
77
|
+
/** Before a link that opens the wallet on this computer. */
|
|
78
|
+
onComputer: string;
|
|
79
|
+
/** The link's words: open a wallet ("NanChat", or `aWallet`). */
|
|
80
|
+
openLink: (wallet: string) => string;
|
|
81
|
+
aWallet: string;
|
|
82
|
+
/** With no QR hook, on a screen with a mouse. */
|
|
83
|
+
openOnPhone: (wallet: string) => string;
|
|
84
|
+
qrLabel: (wallet: string) => string;
|
|
85
|
+
/** Only approve a sign-in the player started, on this host. */
|
|
86
|
+
onlyYours: (host: string) => string;
|
|
87
|
+
/** After the key that opens the app (`yourWallet` for Berry Wallet, which opens any wallet). */
|
|
88
|
+
comeBack: (wallet: string) => string;
|
|
89
|
+
yourWallet: string;
|
|
90
|
+
/** Under a QR code: scan it with this wallet. */
|
|
91
|
+
scanWith: (wallet: string) => string;
|
|
92
|
+
/** Under NanChat's QR code: the phone's camera opens it. */
|
|
93
|
+
scanCamera: (wallet: string) => string;
|
|
94
|
+
/** Before Nautilus's code (the code follows, bold). */
|
|
95
|
+
checkCode: string;
|
|
96
|
+
tryAgain: string;
|
|
97
|
+
otherWay: string;
|
|
98
|
+
expired: string;
|
|
99
|
+
usedElsewhere: string;
|
|
100
|
+
tooManyChecks: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** `words` as a game gives it: any of the keys, and any of the faults. */
|
|
104
|
+
export type PlayerSigninWordsOption = Partial<Omit<PlayerSigninWords, "faults">> & { faults?: Partial<Record<PasskeyFault, FaultText>> };
|
|
105
|
+
|
|
106
|
+
export const PLAYER_SIGNIN_WORDS: PlayerSigninWords = {
|
|
107
|
+
title: "Sign in",
|
|
108
|
+
close: "Close",
|
|
109
|
+
lead: "No password and no payment.",
|
|
110
|
+
usePasskey: "Use a passkey",
|
|
111
|
+
passkeyHint: "Face ID, Touch ID or your device PIN",
|
|
112
|
+
passkeyOff: "Not on this browser",
|
|
113
|
+
lastUsed: "Last used",
|
|
114
|
+
orWallet: "Or a wallet app",
|
|
115
|
+
chooseWallet: "Choose your wallet",
|
|
116
|
+
orPasskey: "Or a passkey",
|
|
117
|
+
back: "Back",
|
|
118
|
+
|
|
119
|
+
passkeyTitle: "Passkey",
|
|
120
|
+
passkeyText: "Face ID, Touch ID or your device PIN. The passkey stays on your device. The game never sees it.",
|
|
121
|
+
useMine: "Use my passkey",
|
|
122
|
+
makeOne: "Make a passkey",
|
|
123
|
+
waitingPasskey: "Waiting for your passkey…",
|
|
124
|
+
makingPasskey: "Making your passkey…",
|
|
125
|
+
openingPasskey: "Opening your passkey…",
|
|
126
|
+
madeTitle: "Your passkey is made",
|
|
127
|
+
madeText: "Press the key to open it.",
|
|
128
|
+
openMine: "Open my passkey",
|
|
129
|
+
spareTitle: "Keep a spare key",
|
|
130
|
+
spareText: "Your looks belong to this passkey. If you lose it, this code brings them back.",
|
|
131
|
+
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.",
|
|
132
|
+
showCode: "Show my code",
|
|
133
|
+
copyCode: "Copy the code",
|
|
134
|
+
copied: "Copied",
|
|
135
|
+
copyFailed: "Copy failed: write it down",
|
|
136
|
+
later: "Later",
|
|
137
|
+
continue: "Continue",
|
|
138
|
+
signingIn: "Signing in…",
|
|
139
|
+
signInFailed: "Sign-in did not work. Try again.",
|
|
140
|
+
faultWallets: "Sign in with a wallet app",
|
|
141
|
+
faults: {
|
|
142
|
+
cancelled: { title: "No passkey was used", text: "No passkey was used." },
|
|
143
|
+
"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." },
|
|
144
|
+
unsupported: { title: "No passkeys here", text: "This browser has no passkeys. Use a wallet app." },
|
|
145
|
+
"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." },
|
|
146
|
+
"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." },
|
|
147
|
+
"wrong-host": { title: "Wrong address", text: (rpId) => `Passkeys for this game work only on ${rpId}.` },
|
|
148
|
+
exists: { title: "You have a passkey", text: "This device has your passkey already. Use it." },
|
|
149
|
+
unstable: { title: "Not on this device", text: "This device gave two answers for one passkey. It cannot sign in here. Use a wallet app." },
|
|
150
|
+
"other-passkey": { title: "Another passkey", text: "That is another passkey. Choose the one you just made. Its name has today's date." },
|
|
151
|
+
failed: { title: "That did not work", text: "The passkey did not work. Try again." },
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
starting: "Starting…",
|
|
155
|
+
startFailed: "Sign-in did not start. Try again.",
|
|
156
|
+
refusal: (text) => text,
|
|
157
|
+
waitingFor: (wallet) => `Waiting for ${wallet}…`,
|
|
158
|
+
signedInAs: (address) => `Signed in as ${address}`,
|
|
159
|
+
openApp: (wallet) => `Open ${wallet}`,
|
|
160
|
+
anotherWallet: "Another wallet",
|
|
161
|
+
onComputer: "On this computer: ",
|
|
162
|
+
openLink: (wallet) => `open ${wallet}`,
|
|
163
|
+
aWallet: "a wallet",
|
|
164
|
+
openOnPhone: (wallet) => `Open this page on your phone to use ${wallet}.`,
|
|
165
|
+
qrLabel: (wallet) => `QR code to sign in with ${wallet}`,
|
|
166
|
+
onlyYours: (host) => `Only approve a sign-in you started yourself, on ${host}.`,
|
|
167
|
+
comeBack: (wallet) => `Approve the sign-in in ${wallet}. Then come back to this page.`,
|
|
168
|
+
yourWallet: "your wallet",
|
|
169
|
+
scanWith: (wallet) => `Scan this with ${wallet} on your phone. Then approve the sign-in.`,
|
|
170
|
+
scanCamera: (wallet) => `Scan this with your phone's camera to open ${wallet}. Then approve the sign-in.`,
|
|
171
|
+
checkCode: "Check that Nautilus shows this code: ",
|
|
172
|
+
tryAgain: "Try again",
|
|
173
|
+
otherWay: "Choose another way",
|
|
174
|
+
expired: "This sign-in expired.",
|
|
175
|
+
usedElsewhere: "This sign-in was used in another browser. Start again.",
|
|
176
|
+
tooManyChecks: "Too many checks. Waiting a moment…",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** 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. */
|
|
180
|
+
export function playerSigninWords(over?: PlayerSigninWordsOption): PlayerSigninWords {
|
|
181
|
+
const out = { ...PLAYER_SIGNIN_WORDS, faults: { ...PLAYER_SIGNIN_WORDS.faults } };
|
|
182
|
+
for (const [k, v] of Object.entries(over ?? {})) {
|
|
183
|
+
if (v === undefined || !(k in PLAYER_SIGNIN_WORDS)) continue;
|
|
184
|
+
if (k === "faults") {
|
|
185
|
+
for (const [code, f] of Object.entries(v as Record<string, FaultText | undefined>)) if (f && code in out.faults) out.faults[code as PasskeyFault] = f;
|
|
186
|
+
} else (out as Record<string, unknown>)[k] = v;
|
|
31
187
|
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What happens after each fault: the passkey step again, or the wallet apps. Not words: the sheet's steps. */
|
|
192
|
+
const NEXT: Record<PasskeyFault, FaultWords["next"]> = {
|
|
193
|
+
cancelled: "stay",
|
|
194
|
+
"no-prf": "wallets",
|
|
195
|
+
unsupported: "wallets",
|
|
196
|
+
"other-form": "wallets",
|
|
197
|
+
"not-a-purse": "stay",
|
|
198
|
+
"wrong-host": "wallets",
|
|
199
|
+
exists: "stay",
|
|
200
|
+
unstable: "wallets",
|
|
201
|
+
"other-passkey": "stay",
|
|
202
|
+
failed: "stay",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** 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". */
|
|
206
|
+
export function faultWords(code: PasskeyFault, rpId: string, words: PlayerSigninWordsOption = PLAYER_SIGNIN_WORDS): FaultWords {
|
|
207
|
+
const known: PasskeyFault = code in NEXT ? code : "failed";
|
|
208
|
+
const f = words.faults?.[known] ?? PLAYER_SIGNIN_WORDS.faults[known];
|
|
209
|
+
return { title: f.title, text: typeof f.text === "function" ? f.text(rpId) : f.text, next: NEXT[known] };
|
|
32
210
|
}
|
package/src/shop/panel/index.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
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
|
|
|
9
11
|
export { mountShop } from "./mount.js";
|
|
@@ -13,3 +15,5 @@ export type { LooksViewOptions, SlotWords } from "./looks.js";
|
|
|
13
15
|
export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
|
|
14
16
|
export type { PayOptions, PayPanel } from "./pay.js";
|
|
15
17
|
export { qrModules, qrSvg, qrVersionFor } from "./qr.js";
|
|
18
|
+
export { COINGECKO_CREDIT, SHOP_WORDS, priceLine, shopWords } from "./words.js";
|
|
19
|
+
export type { ShopWords } from "./words.js";
|
package/src/shop/panel/looks.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { h } from "../../admin/panel/dom.js";
|
|
8
8
|
import { OUTFIT, isOutfit, usdText, wearOf, type Look } from "../../looks/look.js";
|
|
9
|
-
|
|
10
|
-
const HAT_WORDS: Record<string, string> = { cap: "with a cap", beanie: "with a beanie", crown: "with a crown", tophat: "with a top hat" };
|
|
9
|
+
import { shopWords, type ShopWords } from "./words.js";
|
|
11
10
|
|
|
12
11
|
/** A tab's words: its key's word, and its plain choice's name (an item slot's: "Plain shovel", "No charm"). */
|
|
13
12
|
export type SlotWords = { tab: string; plain?: string };
|
|
@@ -32,10 +31,10 @@ export type LooksViewOptions = {
|
|
|
32
31
|
tab?: string;
|
|
33
32
|
/** A tab was chosen: the sheet draws again with it. */
|
|
34
33
|
onTab?: (slot: string) => void;
|
|
34
|
+
/** The game's words (./words.ts): the keys it leaves out keep the defaults. */
|
|
35
|
+
words?: Partial<ShopWords>;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, " ");
|
|
38
|
-
|
|
39
38
|
/** The wear slots of a list, in the order they first show (the outfits first). */
|
|
40
39
|
export function slotsOf(looks: readonly Look[]): string[] {
|
|
41
40
|
const out: string[] = [OUTFIT];
|
|
@@ -62,25 +61,26 @@ function swatchOf(look: Look): HTMLElement {
|
|
|
62
61
|
export function looksView(p: LooksViewOptions): HTMLElement {
|
|
63
62
|
const slots = slotsOf(p.looks);
|
|
64
63
|
const tab = p.tab && slots.includes(p.tab) ? p.tab : slots[0];
|
|
65
|
-
const
|
|
64
|
+
const w = shopWords(p.words);
|
|
65
|
+
const words = (slot: string): SlotWords => p.slots?.[slot] ?? (slot === OUTFIT ? { tab: w.outfits } : { tab: w.slotTab(slot), plain: w.slotPlain(slot) });
|
|
66
66
|
const list = h("ul", { class: "n133s-looks" });
|
|
67
67
|
const row = (pic: HTMLElement, name: string, line: string, action: HTMLElement, worn: boolean) =>
|
|
68
68
|
h("li", { class: `n133s-look${worn ? " n133s-look-worn" : ""}` }, pic, h("span", { class: "n133s-look-text" }, h("strong", {}, name), h("small", {}, line)), action);
|
|
69
69
|
|
|
70
70
|
if (tab !== OUTFIT && p.takeOff) {
|
|
71
71
|
const plain = p.plainWorn ? p.plainWorn(tab) : !p.looks.some((l) => wearOf(l) === tab && p.worn(l));
|
|
72
|
-
const action = h("button", { class: `n133s-btn${plain ? "" : " n133s-primary"}`, type: "button", disabled: plain, onclick: () => p.takeOff!(tab) }, plain ?
|
|
73
|
-
list.append(row(h("span", { class: "n133s-swatch n133s-swatch-plain", "aria-hidden": "true" }), words(tab).plain ??
|
|
72
|
+
const action = h("button", { class: `n133s-btn${plain ? "" : " n133s-primary"}`, type: "button", disabled: plain, onclick: () => p.takeOff!(tab) }, plain ? w.wearing : w.wear);
|
|
73
|
+
list.append(row(h("span", { class: "n133s-swatch n133s-swatch-plain", "aria-hidden": "true" }), words(tab).plain ?? w.slotPlain(tab), w.free, action, plain));
|
|
74
74
|
}
|
|
75
75
|
for (const look of p.looks) {
|
|
76
76
|
if (wearOf(look) !== tab) continue;
|
|
77
77
|
const mine = look.priceUsd === 0 || p.owned.has(look.id);
|
|
78
78
|
const worn = p.worn(look);
|
|
79
79
|
const action = mine
|
|
80
|
-
? h("button", { class: `n133s-btn${worn ? "" : " n133s-primary"}`, type: "button", disabled: worn, onclick: () => p.wear(look) }, worn ?
|
|
81
|
-
: h("button", { class: "n133s-btn n133s-buy", type: "button", onclick: () => p.buy(look) },
|
|
82
|
-
const what = isOutfit(look) ? (look.change.hat
|
|
83
|
-
const state = mine ?
|
|
80
|
+
? h("button", { class: `n133s-btn${worn ? "" : " n133s-primary"}`, type: "button", disabled: worn, onclick: () => p.wear(look) }, worn ? w.wearing : w.wear)
|
|
81
|
+
: h("button", { class: "n133s-btn n133s-buy", type: "button", onclick: () => p.buy(look) }, w.buy(usdText(look.priceUsd)));
|
|
82
|
+
const what = isOutfit(look) ? w.outfitLine(look.change.hat ?? null) : w.itemLine(tab);
|
|
83
|
+
const state = mine ? ` · ${look.priceUsd === 0 ? w.free : w.yours}` : "";
|
|
84
84
|
list.append(row(p.picture?.(look) ?? swatchOf(look), look.name, `${what}${state}`, action, worn));
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -88,7 +88,7 @@ export function looksView(p: LooksViewOptions): HTMLElement {
|
|
|
88
88
|
slots.length > 1
|
|
89
89
|
? h(
|
|
90
90
|
"div",
|
|
91
|
-
{ class: "n133s-tabs", role: "tablist", "aria-label":
|
|
91
|
+
{ class: "n133s-tabs", role: "tablist", "aria-label": w.kinds },
|
|
92
92
|
...slots.map((s) =>
|
|
93
93
|
h("button", { class: "n133s-tab", type: "button", role: "tab", "aria-selected": s === tab ? "true" : "false", onclick: () => p.onTab?.(s) }, words(s).tab),
|
|
94
94
|
),
|
|
@@ -100,7 +100,7 @@ export function looksView(p: LooksViewOptions): HTMLElement {
|
|
|
100
100
|
{ class: "n133s-looks-view" },
|
|
101
101
|
tabs,
|
|
102
102
|
list,
|
|
103
|
-
h("p", { class: "n133s-fine" },
|
|
104
|
-
p.signedIn ? null : h("p", { class: "n133s-fine" },
|
|
103
|
+
h("p", { class: "n133s-fine" }, w.looksNote),
|
|
104
|
+
p.signedIn ? null : h("p", { class: "n133s-fine" }, w.signInFirst),
|
|
105
105
|
);
|
|
106
106
|
}
|