@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.
- package/CHANGELOG.md +51 -0
- package/README.md +62 -12
- package/ai/SPEC.md +7 -1
- package/dist/admin/amount.d.ts +4 -0
- package/dist/admin/amount.js +4 -0
- package/dist/admin/panel/dom.d.ts +1 -1
- package/dist/admin/panel/dom.js +3 -2
- package/dist/admin/panel/shelf.js +1 -1
- package/dist/admin/panel/shelfData.d.ts +1 -1
- package/dist/admin/panel/shelfData.js +6 -6
- package/dist/admin/summary.d.ts +1 -1
- package/dist/admin/summary.js +8 -8
- package/dist/looks/index.d.ts +1 -1
- package/dist/looks/index.js +1 -1
- package/dist/looks/ladder.d.ts +1 -1
- package/dist/looks/ladder.js +7 -7
- package/dist/looks/look.d.ts +7 -2
- package/dist/looks/look.js +10 -3
- 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/handler.js +2 -2
- 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/admin/amount.ts +6 -0
- package/src/admin/panel/dom.ts +4 -2
- package/src/admin/panel/shelf.ts +1 -1
- package/src/admin/panel/shelfData.ts +6 -6
- package/src/admin/summary.ts +8 -8
- package/src/looks/index.ts +1 -1
- package/src/looks/ladder.ts +7 -6
- package/src/looks/look.ts +12 -3
- 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/handler.ts +2 -2
- 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
package/src/admin/summary.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The numbers of the summary from the rows of the orders, the same for every adapter: the counts by status, the totals and the shape of an order.
|
|
2
2
|
// An adapter reads its rows; this file turns them into a `StoreSummary`.
|
|
3
3
|
|
|
4
|
-
import { isRawText, xnoText } from "./amount.js";
|
|
4
|
+
import { isRawText, usdDecimals, xnoText } from "./amount.js";
|
|
5
5
|
import type { AdminOrder, Money, StoreSummary } from "./store.js";
|
|
6
6
|
|
|
7
7
|
/** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
|
|
@@ -21,21 +21,21 @@ export type OrderRow = {
|
|
|
21
21
|
|
|
22
22
|
export const ORDERS_SHOWN = 500;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
24
|
+
/** Steps of $0.0001 from a dollar text like "0.25" or "0.005". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
|
|
25
|
+
const toSteps = (usd: string): number => {
|
|
26
26
|
if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd)) return 0;
|
|
27
|
-
return Math.round(Number(usd) *
|
|
27
|
+
return Math.round(Number(usd) * 10_000);
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
/** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in
|
|
30
|
+
/** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in whole $0.0001 steps, so 0.1 + 0.2 is 0.30). */
|
|
31
31
|
export function moneyOf(rows: readonly { amountRaw: string; usd: string }[]): Money {
|
|
32
32
|
let raw = 0n;
|
|
33
|
-
let
|
|
33
|
+
let steps = 0;
|
|
34
34
|
for (const r of rows) {
|
|
35
35
|
if (isRawText(r.amountRaw)) raw += BigInt(r.amountRaw);
|
|
36
|
-
|
|
36
|
+
steps += toSteps(r.usd);
|
|
37
37
|
}
|
|
38
|
-
return { count: rows.length, xno: xnoText(raw.toString()), usd: (
|
|
38
|
+
return { count: rows.length, xno: xnoText(raw.toString()), usd: usdDecimals(steps / 10_000) };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Where an order counts: an open order past its expiry counts as expired. */
|
package/src/looks/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
|
|
1
|
+
export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, PRICE_STEP_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, priceStepOk, lookProblems, checkLooks, cleanSlots, slotValueOk, usdDecimals, usdText, wearOf, isOutfit } from "./look.js";
|
|
2
2
|
export type { Change, Hat, Look, Problem, Slots } from "./look.js";
|
|
3
3
|
export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
|
|
4
4
|
export type { Rung } from "./ladder.js";
|
package/src/looks/ladder.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The price ladder: the six steps from a cent to a dollar that nano133.com uses for its own cosmetics and shows on /build/cosmetics.
|
|
2
|
-
// It is a suggestion. A look can have any price with at most
|
|
2
|
+
// It is a suggestion. A look can have any price with at most 4 decimals ($0.0001 steps), up to $1000.
|
|
3
|
+
|
|
4
|
+
import { usdText } from "./look.js";
|
|
3
5
|
|
|
4
6
|
export type Rung = {
|
|
5
7
|
name: "dye" | "charm" | "piece" | "statement" | "set" | "outfit";
|
|
@@ -26,10 +28,9 @@ export function rungOf(usd: number): Rung | undefined {
|
|
|
26
28
|
return PRICE_LADDER.find((r) => Math.abs(rungUsd(r) - usd) < 1e-9);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/** A price as a page writes it: 1¢, 25¢, $1, $1.10. */
|
|
31
|
+
/** A price as a page writes it: 0.5¢, 1¢, 25¢, $1, $1.10, $1.005. */
|
|
30
32
|
export function priceText(usd: number): string {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
|
|
33
|
+
const steps = Math.round(usd * 10_000);
|
|
34
|
+
if (steps < 10_000) return `${steps / 100}¢`;
|
|
35
|
+
return steps % 10_000 === 0 ? `$${steps / 10_000}` : usdText(steps / 10_000);
|
|
35
36
|
}
|
package/src/looks/look.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type Look = {
|
|
|
17
17
|
id: string;
|
|
18
18
|
/** What the player reads, 1 to 40 characters. */
|
|
19
19
|
name: string;
|
|
20
|
-
/** The price in US dollars with at most
|
|
20
|
+
/** The price in US dollars with at most 4 decimals, for example 0.25 or 0.005. 0 means free for everyone. */
|
|
21
21
|
priceUsd: number;
|
|
22
22
|
/**
|
|
23
23
|
* The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
|
|
@@ -73,6 +73,14 @@ export function wholeCents(usd: number): boolean {
|
|
|
73
73
|
return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** The smallest step of a price: $0.0001, a hundredth of a cent. A price above 0 is at least this, and the shop asks for at least one 0.0001 XNO step. */
|
|
77
|
+
export const PRICE_STEP_USD = 0.0001;
|
|
78
|
+
|
|
79
|
+
/** Whether a price has at most 4 decimals (whole steps of $0.0001), by the same rule as `wholeCents`: it is exactly the number its own four-decimal text names. */
|
|
80
|
+
export function priceStepOk(usd: number): boolean {
|
|
81
|
+
return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(4)) === usd;
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
/** Every mistake in a list of looks (looks.json as it was read), in the order of the list. An empty array means the list is good. */
|
|
77
85
|
export function lookProblems(raw: unknown): Problem[] {
|
|
78
86
|
const out: Problem[] = [];
|
|
@@ -88,7 +96,7 @@ export function lookProblems(raw: unknown): Problem[] {
|
|
|
88
96
|
else seen.add(v.id);
|
|
89
97
|
if (typeof v.name !== "string" || !v.name.trim() || v.name.length > NAME_MAX) add("name", '"name" must be 1 to 40 characters');
|
|
90
98
|
if (typeof v.priceUsd !== "number" || !Number.isFinite(v.priceUsd) || v.priceUsd < 0 || v.priceUsd > PRICE_MAX_USD) add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
|
|
91
|
-
else if (!
|
|
99
|
+
else if (!priceStepOk(v.priceUsd)) add("priceUsd", '"priceUsd" can have at most 4 decimals (0.0001 is the smallest price above 0)');
|
|
92
100
|
const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
|
|
93
101
|
if (!wearOk) add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
|
|
94
102
|
const outfit = v.wear === undefined || v.wear === OUTFIT;
|
|
@@ -160,7 +168,8 @@ export function checkLooks(rawInput: unknown): Look[] {
|
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
/** "$0.25" */
|
|
163
|
-
|
|
171
|
+
/** "$0.25", "$1.00", "$0.005"; and the same without "$", as an order keeps it ("0.005"). The money code's own helpers. */
|
|
172
|
+
export { usdDecimals, usdText } from "../admin/amount.js";
|
|
164
173
|
|
|
165
174
|
/** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
|
|
166
175
|
export const wearOf = (l: Pick<Look, "wear">): string => l.wear ?? OUTFIT;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// "@nano133/nanoterra/player-signin/panel": the player's sign-in sheet in the browser. `mountPlayerSignin` is the sheet (call `open()`);
|
|
2
|
-
// `signInView` is its content, for a game that has a sheet of its own. Add "@nano133/nanoterra/player-signin/style.css" to the page.
|
|
2
|
+
// `signInView` is its content, for a game that has a sheet of its own. Add "@nano133/nanoterra/player-signin/style.css" to the page. Every word of
|
|
3
|
+
// the sheet can be the game's own: `words` (PlayerSigninWords; PLAYER_SIGNIN_WORDS are the defaults).
|
|
3
4
|
|
|
4
5
|
export { mountPlayerSignin } from "./mount.js";
|
|
5
6
|
export type { MountedPlayerSignin, PlayerSigninMountOptions } from "./mount.js";
|
|
6
7
|
export { hostFits, passkeyName, signInView } from "./view.js";
|
|
7
8
|
export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
|
|
8
9
|
export type { PlayerSigninView, PlayerSigninViewOptions, SignedIn } from "./view.js";
|
|
9
|
-
export { faultWords } from "./words.js";
|
|
10
|
-
export type { FaultWords } from "./words.js";
|
|
10
|
+
export { PLAYER_SIGNIN_WORDS, faultWords, playerSigninWords } from "./words.js";
|
|
11
|
+
export type { FaultText, FaultWords, PlayerSigninWords, PlayerSigninWordsOption } from "./words.js";
|
|
11
12
|
export { readMemo } from "./memo.js";
|
|
12
13
|
export type { SigninMemo } from "./memo.js";
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// The sign-in sheet of a game: `mountPlayerSignin(element, options)`. It draws no key of its own: the game's account key, the shop's "sign in
|
|
2
2
|
// first" and anything else call `open()`, which resolves with the player once they are signed in, or null when the sheet closes. Plain DOM; a
|
|
3
3
|
// modal sheet with a scrim: from the bottom on a phone held upright, in the middle on a wide or sideways screen. Escape, the close key and a press
|
|
4
|
-
// on the scrim close it.
|
|
4
|
+
// on the scrim close it. Phone first: text 16 px, every key 48 px or more each way.
|
|
5
5
|
|
|
6
6
|
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
7
7
|
import { icon } from "../../admin/panel/icons.js";
|
|
8
8
|
import { signInView, type PlayerSigninViewOptions, type SignedIn } from "./view.js";
|
|
9
|
+
import { playerSigninWords } from "./words.js";
|
|
9
10
|
|
|
10
11
|
export type PlayerSigninMountOptions = PlayerSigninViewOptions & {
|
|
11
|
-
/** The sheet's title. Default "Sign in". */
|
|
12
|
+
/** The sheet's title. Default: `words.title` ("Sign in"). */
|
|
12
13
|
title?: string;
|
|
13
14
|
/** How long "Signed in as …" shows before the sheet closes, in ms. Default 900. */
|
|
14
15
|
closeAfterMs?: number;
|
|
@@ -36,6 +37,7 @@ export function mountPlayerSignin(root: HTMLElement, options: PlayerSigninMountO
|
|
|
36
37
|
let pending: { promise: Promise<SignedIn | null>; resolve: (p: SignedIn | null) => void } | null = null;
|
|
37
38
|
let before: Element | null = null;
|
|
38
39
|
let closing: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const words = playerSigninWords(options.words);
|
|
39
41
|
|
|
40
42
|
const settle = (p: SignedIn | null) => {
|
|
41
43
|
const was = pending;
|
|
@@ -52,8 +54,8 @@ export function mountPlayerSignin(root: HTMLElement, options: PlayerSigninMountO
|
|
|
52
54
|
},
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ??
|
|
56
|
-
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label":
|
|
57
|
+
const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? words.title);
|
|
58
|
+
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
|
|
57
59
|
const scrim = h("div", { class: "n133s-scrim" });
|
|
58
60
|
const panel = h("section", { class: "n133s-panel", role: "dialog", "aria-modal": "true", "aria-labelledby": `n133s-title-${n}` }, h("header", { class: "n133s-head" }, titleEl, closeKey), h("div", { class: "n133s-body" }, view.node));
|
|
59
61
|
const wrap = h("div", { class: "n133-signin n133s-sheet", "data-open": "false" }, scrim, panel);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* The player's sign-in sheet (mountPlayerSignin in ./mount.ts) and its content (signInView in ./view.ts). Every rule is under .n133-signin, so nothing
|
|
2
2
|
leaks into the game's page.
|
|
3
|
-
Phone first: every key is
|
|
3
|
+
Phone first: text is 16 px, and every key, tile, link, close key and back key is 48 px or more each way. On a phone held upright the sheet comes from the bottom; on a wide or sideways screen it
|
|
4
4
|
is a card in the middle. A scrim covers the game while it is open.
|
|
5
5
|
Light and dark: the sheet follows data-n133-theme="light" | "dark" on the page's <html> when the page sets it, else the device's prefers-color-scheme.
|
|
6
6
|
To theme it, set the --n133-* custom properties on the element that holds the sheet (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
|
|
@@ -129,8 +129,8 @@
|
|
|
129
129
|
.n133-signin .n133s-close {
|
|
130
130
|
display: inline-grid;
|
|
131
131
|
place-items: center;
|
|
132
|
-
width:
|
|
133
|
-
height:
|
|
132
|
+
width: 48px;
|
|
133
|
+
height: 48px;
|
|
134
134
|
border: 0;
|
|
135
135
|
border-radius: 50%;
|
|
136
136
|
background: transparent;
|
|
@@ -170,12 +170,12 @@
|
|
|
170
170
|
|
|
171
171
|
.n133-signin .n133s-fine {
|
|
172
172
|
margin: 0;
|
|
173
|
-
font-size:
|
|
173
|
+
font-size: 16px;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
.n133-signin .n133s-or {
|
|
177
177
|
margin: 6px 0 -4px;
|
|
178
|
-
font: 650
|
|
178
|
+
font: 650 16px/1.3 var(--font);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
.n133-signin .n133s-step {
|
|
@@ -251,7 +251,7 @@
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
.n133-signin .n133s-pk-words small {
|
|
254
|
-
font-size:
|
|
254
|
+
font-size: 16px;
|
|
255
255
|
opacity: 0.92;
|
|
256
256
|
}
|
|
257
257
|
|
|
@@ -292,26 +292,26 @@
|
|
|
292
292
|
border-radius: 12px;
|
|
293
293
|
background: var(--ground);
|
|
294
294
|
color: var(--ink);
|
|
295
|
-
font: 800
|
|
295
|
+
font: 800 16px/1 var(--font);
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
.n133-signin .n133s-name {
|
|
299
|
-
font: 650
|
|
299
|
+
font: 650 16px/1.2 var(--font);
|
|
300
300
|
text-align: center;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
.n133-signin .n133s-last {
|
|
304
|
-
|
|
305
|
-
top: 6px;
|
|
306
|
-
right: 8px;
|
|
307
|
-
padding: 1px 7px;
|
|
304
|
+
padding: 0 8px;
|
|
308
305
|
border-radius: 999px;
|
|
309
306
|
background: var(--ground);
|
|
310
307
|
color: var(--ink-soft);
|
|
311
|
-
font: 700
|
|
308
|
+
font: 700 16px/24px var(--font);
|
|
309
|
+
white-space: nowrap;
|
|
312
310
|
}
|
|
313
311
|
|
|
312
|
+
/* "Last used": in the flow, so it never covers a word at 16 px: under the name in a tile, at the right end of the passkey's row. */
|
|
314
313
|
.n133-signin .n133s-pk .n133s-last {
|
|
314
|
+
margin-left: auto;
|
|
315
315
|
background: rgb(255 255 255 / 0.22);
|
|
316
316
|
color: inherit;
|
|
317
317
|
}
|
|
@@ -376,14 +376,15 @@
|
|
|
376
376
|
align-self: flex-start;
|
|
377
377
|
align-items: center;
|
|
378
378
|
gap: 6px;
|
|
379
|
-
min-
|
|
379
|
+
min-width: 48px;
|
|
380
|
+
min-height: 48px;
|
|
380
381
|
margin: 0 0 -4px -10px;
|
|
381
|
-
padding: 0 10px;
|
|
382
|
+
padding: 0 12px 0 10px;
|
|
382
383
|
border: 0;
|
|
383
384
|
border-radius: 10px;
|
|
384
385
|
background: transparent;
|
|
385
386
|
color: var(--ink-soft);
|
|
386
|
-
font: 650
|
|
387
|
+
font: 650 16px/1 var(--font);
|
|
387
388
|
cursor: pointer;
|
|
388
389
|
}
|
|
389
390
|
|
|
@@ -393,6 +394,10 @@
|
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
.n133-signin a:not(.n133s-btn) {
|
|
397
|
+
display: inline-flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
min-width: 48px;
|
|
400
|
+
min-height: 48px;
|
|
396
401
|
color: var(--gem-dark);
|
|
397
402
|
font-weight: 650;
|
|
398
403
|
text-underline-offset: 2px;
|
|
@@ -443,7 +448,7 @@
|
|
|
443
448
|
min-height: 1.4em;
|
|
444
449
|
margin: 12px 0 0;
|
|
445
450
|
color: var(--ink-soft);
|
|
446
|
-
font-size:
|
|
451
|
+
font-size: 16px;
|
|
447
452
|
}
|
|
448
453
|
|
|
449
454
|
.n133-signin .n133s-status:empty {
|
|
@@ -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
|
}
|