@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
|
@@ -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/handler.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// shows "TEST MODE: DO NOT PAY". A payment to an address nobody holds the key for is lost for good.
|
|
16
16
|
|
|
17
17
|
import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
|
|
18
|
-
import { checkLooks, type Look } from "../looks/look.js";
|
|
18
|
+
import { checkLooks, usdDecimals, type Look } from "../looks/look.js";
|
|
19
19
|
import { payeeOf } from "./address.js";
|
|
20
20
|
import { nanoUri, priceRaw, xnoText } from "./amount.js";
|
|
21
21
|
import { PUBLIC_NODES, type Fetch, type NanoNode, type RpcOptions } from "./nodes.js";
|
|
@@ -157,7 +157,7 @@ export function createShopHandler(options: ShopHandlerOptions): ShopHandler {
|
|
|
157
157
|
const price = priceRaw(look.priceUsd, rate.usd);
|
|
158
158
|
if (!price) return fail(503, "Prices are not available now. Try again later.");
|
|
159
159
|
try {
|
|
160
|
-
const order = await reserveOrder(store, { player, lookId: look.id, usd: look.priceUsd
|
|
160
|
+
const order = await reserveOrder(store, { player, lookId: look.id, usd: usdDecimals(look.priceUsd), rate: rate.usd, price, visitor, now: at });
|
|
161
161
|
return json(payView(order, at), 201);
|
|
162
162
|
} catch (e) {
|
|
163
163
|
if (e instanceof ShopBusy) return fail(503, "Many players are paying this price right now. Try again in a minute.");
|
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
|
}
|
package/src/shop/panel/mount.ts
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
// owned() the looks the player owns (the game's GET /api/me gives them) signedIn() whether a player is signed in
|
|
5
5
|
// signIn() the game's own sign-in (the kit's sign-in path, or the game's): true when the player is signed in after it. The shop draws no sign-in.
|
|
6
6
|
// worn/wear what the player wears; `onOwned(lookId)` when a paid order gave a look (wear it)
|
|
7
|
+
// onSignInCancel(lookId) the game's own Buy (`buy(lookId)`) asked for a sign-in and the player closed it: the game shows its own sheet again
|
|
7
8
|
//
|
|
8
|
-
// `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows.
|
|
9
|
-
//
|
|
9
|
+
// `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows. `buy(lookId)` starts on the next frame:
|
|
10
|
+
// a game's own <dialog> that closed just before has had its `close` event by then (Chromium fires it at the frame), so its handler cannot take the focus.
|
|
11
|
+
// The sheet is a <dialog>: it rises from the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay
|
|
12
|
+
// sheet puts the QR code beside the words. Phone first: text 16 px, every key 48 px or more each way.
|
|
13
|
+
//
|
|
14
|
+
// `words` changes any word of the two sheets (./words.ts: SHOP_WORDS are the defaults), for a game's own words or language.
|
|
10
15
|
|
|
11
16
|
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
12
17
|
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
@@ -15,6 +20,7 @@ import { checkLooks, wearOf, type Look } from "../../looks/look.js";
|
|
|
15
20
|
import type { PayView } from "../handler.js";
|
|
16
21
|
import { looksView, slotsOf, type SlotWords } from "./looks.js";
|
|
17
22
|
import { payPanel } from "./pay.js";
|
|
23
|
+
import { shopWords, type ShopWords } from "./words.js";
|
|
18
24
|
|
|
19
25
|
export type ShopMountOptions = {
|
|
20
26
|
/** The game's looks (looks.json as it was read). The server decides every price; these are for the list. */
|
|
@@ -26,6 +32,16 @@ export type ShopMountOptions = {
|
|
|
26
32
|
signedIn(): boolean;
|
|
27
33
|
/** The game's sign-in: true when a player is signed in after it. With none, Buy asks the player to sign in and stops. */
|
|
28
34
|
signIn?: () => Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* The game's own Buy (`buy(lookId)`) asked for a sign-in, and the player closed it without signing in: the game shows its own looks sheet again.
|
|
37
|
+
* The kit's list does not open then (the kit's sheet closes if it was open). A Buy in the kit's own list goes back to that list, as before.
|
|
38
|
+
*/
|
|
39
|
+
onSignInCancel?: (lookId: string, look: Look) => void;
|
|
40
|
+
/**
|
|
41
|
+
* The server refused an order with 401: the player's session ended, though `signedIn()` said yes. The game marks the player signed out. The sheet's
|
|
42
|
+
* "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot post the same refused order again.
|
|
43
|
+
*/
|
|
44
|
+
onSessionEnd?: () => void;
|
|
29
45
|
/** Whether the player wears this look now. */
|
|
30
46
|
worn(look: Look): boolean;
|
|
31
47
|
/** Wear a look the player owns. */
|
|
@@ -40,8 +56,10 @@ export type ShopMountOptions = {
|
|
|
40
56
|
picture?: (look: Look) => HTMLElement | null;
|
|
41
57
|
/** Words for the tabs of the wear slots. */
|
|
42
58
|
slots?: Record<string, SlotWords>;
|
|
43
|
-
/** The sheet's title. Default "Looks". */
|
|
59
|
+
/** The sheet's title. Default: `words.title` ("Looks"). */
|
|
44
60
|
title?: string;
|
|
61
|
+
/** The game's words for the two sheets, over the defaults (SHOP_WORDS): `{ buyTitle: (look) => look, sendExactly: "Pay exactly" }`. */
|
|
62
|
+
words?: Partial<ShopWords>;
|
|
45
63
|
/** Draw the Looks key. Default true; false: the game calls `open()` from its own key. */
|
|
46
64
|
key?: boolean;
|
|
47
65
|
/** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
|
|
@@ -58,7 +76,10 @@ export type MountedShop = {
|
|
|
58
76
|
/** Opens the sheet on the looks. */
|
|
59
77
|
open(): void;
|
|
60
78
|
close(): void;
|
|
61
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Opens the sheet on Buy for one look (the game's own "Buy" elsewhere), on the next frame: close the game's own <dialog> first. A sign-in closed
|
|
81
|
+
* without a sign-in calls `onSignInCancel` and opens nothing.
|
|
82
|
+
*/
|
|
62
83
|
buy(lookId: string): void;
|
|
63
84
|
/** Draws the looks again (after the player signed in, or wore something). */
|
|
64
85
|
refresh(): void;
|
|
@@ -75,12 +96,14 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
75
96
|
let stopPay: (() => void) | null = null;
|
|
76
97
|
let destroyed = false;
|
|
77
98
|
const cleanup: (() => void)[] = [];
|
|
99
|
+
const words = shopWords(options.words);
|
|
100
|
+
const sheetTitle = options.title ?? words.title;
|
|
78
101
|
|
|
79
102
|
const title = h("h2", { class: "n133s-title", tabindex: "-1" });
|
|
80
|
-
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label":
|
|
103
|
+
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
|
|
81
104
|
const body = h("div", { class: "n133s-body" });
|
|
82
|
-
const dialog = h("dialog", { class: "n133s-sheet", "aria-label":
|
|
83
|
-
const key = options.key === false ? null : h("button", { class: "n133s-key", type: "button", "aria-haspopup": "dialog" }, icon("shirt", 20), h("span", { class: "n133s-key-word" },
|
|
105
|
+
const dialog = h("dialog", { class: "n133s-sheet", "aria-label": sheetTitle }, h("header", { class: "n133s-head" }, title, closeKey), body);
|
|
106
|
+
const key = options.key === false ? null : h("button", { class: "n133s-key", type: "button", "aria-haspopup": "dialog" }, icon("shirt", 20), h("span", { class: "n133s-key-word" }, sheetTitle));
|
|
84
107
|
const wrap = h("div", { class: "n133-shop" }, key, dialog);
|
|
85
108
|
root.append(wrap);
|
|
86
109
|
|
|
@@ -147,7 +170,7 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
147
170
|
function showLooks() {
|
|
148
171
|
if (destroyed) return;
|
|
149
172
|
show(
|
|
150
|
-
|
|
173
|
+
sheetTitle,
|
|
151
174
|
looksView({
|
|
152
175
|
looks,
|
|
153
176
|
owned: ownedNow(),
|
|
@@ -163,9 +186,10 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
163
186
|
showLooks();
|
|
164
187
|
}
|
|
165
188
|
: undefined,
|
|
166
|
-
buy: (l) => void buy(l),
|
|
189
|
+
buy: (l) => void buy(l, false),
|
|
167
190
|
picture: options.picture,
|
|
168
191
|
slots: options.slots,
|
|
192
|
+
words,
|
|
169
193
|
tab,
|
|
170
194
|
onTab: (s) => {
|
|
171
195
|
tab = s;
|
|
@@ -177,23 +201,31 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
const back = (message: string, extra?: HTMLElement | null) =>
|
|
180
|
-
h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() },
|
|
204
|
+
h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() }, words.back));
|
|
181
205
|
|
|
182
|
-
|
|
206
|
+
/**
|
|
207
|
+
* `game`: the game's own Buy started it (`buy(lookId)`), not the kit's list: a closed sign-in goes back to the game, not to the list.
|
|
208
|
+
* `ended`: the server said the session ended (401): sign in again whatever `signedIn()` says.
|
|
209
|
+
*/
|
|
210
|
+
async function buy(look: Look, game: boolean, ended = false) {
|
|
183
211
|
if (destroyed) return;
|
|
184
212
|
tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
|
|
185
|
-
if (!options.signedIn()) {
|
|
186
|
-
if (!options.signIn) return show(
|
|
213
|
+
if (ended || !options.signedIn()) {
|
|
214
|
+
if (!options.signIn) return show(words.buyTitle(look.name), back(words.signInFirst));
|
|
187
215
|
let ok = false;
|
|
188
216
|
try {
|
|
189
217
|
ok = await options.signIn();
|
|
190
218
|
} catch {
|
|
191
219
|
ok = false;
|
|
192
220
|
}
|
|
193
|
-
if (!ok || !options.signedIn())
|
|
221
|
+
if (!ok || !options.signedIn()) {
|
|
222
|
+
if (!game) return showLooks();
|
|
223
|
+
close();
|
|
224
|
+
return options.onSignInCancel?.(look.id, look);
|
|
225
|
+
}
|
|
194
226
|
}
|
|
195
227
|
const box = h("div", { class: "n133s-turnstile", hidden: true });
|
|
196
|
-
show(
|
|
228
|
+
show(words.buyTitle(look.name), h("div", {}, h("p", { class: "n133s-status" }, words.making), box));
|
|
197
229
|
try {
|
|
198
230
|
const turnstile = options.personCheck ? await options.personCheck(box) : null;
|
|
199
231
|
const order = await api.post<PayView>("", { lookId: look.id, turnstile });
|
|
@@ -201,18 +233,21 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
201
233
|
const pay = payPanel(order, {
|
|
202
234
|
lookName: look.name,
|
|
203
235
|
check: (id) => api.post<PayView>(`/${id}/check`),
|
|
204
|
-
errorText: (e) => (e instanceof ApiError ? e.message : null),
|
|
236
|
+
errorText: (e) => (e instanceof ApiError ? words.refusal(e.message, e.status) : null),
|
|
237
|
+
words,
|
|
205
238
|
onPaid: () => {
|
|
206
239
|
bought.add(look.id);
|
|
207
240
|
options.onOwned?.(look.id, look);
|
|
208
241
|
},
|
|
209
242
|
});
|
|
210
|
-
show(
|
|
243
|
+
show(words.buyTitle(look.name), pay.node);
|
|
211
244
|
stopPay = pay.stop;
|
|
212
245
|
} catch (e) {
|
|
213
|
-
const message = e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message :
|
|
214
|
-
const
|
|
215
|
-
|
|
246
|
+
const message = e instanceof ApiError ? words.refusal(e.message, e.status) : e instanceof Error && e.message ? words.refusal(e.message, 0) : words.orderFailed;
|
|
247
|
+
const gone = e instanceof ApiError && e.status === 401;
|
|
248
|
+
if (gone) options.onSessionEnd?.();
|
|
249
|
+
const again = gone && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look, game, true) }, words.signIn) : null;
|
|
250
|
+
show(words.buyTitle(look.name), back(message, again));
|
|
216
251
|
}
|
|
217
252
|
}
|
|
218
253
|
|
|
@@ -221,7 +256,11 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
|
|
|
221
256
|
close,
|
|
222
257
|
buy(lookId) {
|
|
223
258
|
const look = looks.find((l) => l.id === lookId);
|
|
224
|
-
|
|
259
|
+
// On the next frame (rAF; a hidden page has none): a <dialog> of the game's that closed just before has had its `close` event by then.
|
|
260
|
+
if (!look) return;
|
|
261
|
+
const go = () => void buy(look, true);
|
|
262
|
+
if (typeof requestAnimationFrame === "function" && !globalThis.document?.hidden) requestAnimationFrame(go);
|
|
263
|
+
else setTimeout(go, 0);
|
|
225
264
|
},
|
|
226
265
|
refresh() {
|
|
227
266
|
if (isOpen() && body.querySelector(".n133s-looks-view")) showLooks();
|