@nano133/nanoterra 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -12
  3. package/ai/SPEC.md +7 -1
  4. package/dist/admin/amount.d.ts +4 -0
  5. package/dist/admin/amount.js +4 -0
  6. package/dist/admin/panel/dom.d.ts +1 -1
  7. package/dist/admin/panel/dom.js +3 -2
  8. package/dist/admin/panel/shelf.js +1 -1
  9. package/dist/admin/panel/shelfData.d.ts +1 -1
  10. package/dist/admin/panel/shelfData.js +6 -6
  11. package/dist/admin/summary.d.ts +1 -1
  12. package/dist/admin/summary.js +8 -8
  13. package/dist/looks/index.d.ts +1 -1
  14. package/dist/looks/index.js +1 -1
  15. package/dist/looks/ladder.d.ts +1 -1
  16. package/dist/looks/ladder.js +7 -7
  17. package/dist/looks/look.d.ts +7 -2
  18. package/dist/looks/look.js +10 -3
  19. package/dist/player-signin/panel/index.d.ts +2 -2
  20. package/dist/player-signin/panel/index.js +3 -2
  21. package/dist/player-signin/panel/mount.d.ts +1 -1
  22. package/dist/player-signin/panel/mount.js +5 -3
  23. package/dist/player-signin/panel/view.d.ts +3 -0
  24. package/dist/player-signin/panel/view.js +49 -46
  25. package/dist/player-signin/panel/words.d.ts +96 -1
  26. package/dist/player-signin/panel/words.js +109 -24
  27. package/dist/shop/handler.js +2 -2
  28. package/dist/shop/panel/index.d.ts +2 -0
  29. package/dist/shop/panel/index.js +3 -0
  30. package/dist/shop/panel/looks.d.ts +3 -0
  31. package/dist/shop/panel/looks.js +11 -11
  32. package/dist/shop/panel/mount.d.ts +18 -2
  33. package/dist/shop/panel/mount.js +48 -21
  34. package/dist/shop/panel/pay.d.ts +3 -0
  35. package/dist/shop/panel/pay.js +38 -25
  36. package/dist/shop/panel/words.d.ts +89 -0
  37. package/dist/shop/panel/words.js +79 -0
  38. package/package.json +1 -1
  39. package/src/admin/amount.ts +6 -0
  40. package/src/admin/panel/dom.ts +4 -2
  41. package/src/admin/panel/shelf.ts +1 -1
  42. package/src/admin/panel/shelfData.ts +6 -6
  43. package/src/admin/summary.ts +8 -8
  44. package/src/looks/index.ts +1 -1
  45. package/src/looks/ladder.ts +7 -6
  46. package/src/looks/look.ts +12 -3
  47. package/src/player-signin/panel/index.ts +4 -3
  48. package/src/player-signin/panel/mount.ts +6 -4
  49. package/src/player-signin/panel/style.css +22 -17
  50. package/src/player-signin/panel/view.ts +60 -55
  51. package/src/player-signin/panel/words.ts +202 -24
  52. package/src/shop/handler.ts +2 -2
  53. package/src/shop/panel/index.ts +4 -0
  54. package/src/shop/panel/looks.ts +14 -14
  55. package/src/shop/panel/mount.ts +60 -21
  56. package/src/shop/panel/pay.ts +51 -30
  57. package/src/shop/panel/style.css +24 -20
  58. package/src/shop/panel/words.ts +172 -0
@@ -1,5 +1,6 @@
1
1
  import { type Look } from "../../looks/look.js";
2
2
  import { type SlotWords } from "./looks.js";
3
+ import { type ShopWords } from "./words.js";
3
4
  export type ShopMountOptions = {
4
5
  /** The game's looks (looks.json as it was read). The server decides every price; these are for the list. */
5
6
  looks: unknown;
@@ -10,6 +11,16 @@ export type ShopMountOptions = {
10
11
  signedIn(): boolean;
11
12
  /** 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. */
12
13
  signIn?: () => Promise<boolean>;
14
+ /**
15
+ * 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.
16
+ * 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.
17
+ */
18
+ onSignInCancel?: (lookId: string, look: Look) => void;
19
+ /**
20
+ * 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
21
+ * "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot post the same refused order again.
22
+ */
23
+ onSessionEnd?: () => void;
13
24
  /** Whether the player wears this look now. */
14
25
  worn(look: Look): boolean;
15
26
  /** Wear a look the player owns. */
@@ -24,8 +35,10 @@ export type ShopMountOptions = {
24
35
  picture?: (look: Look) => HTMLElement | null;
25
36
  /** Words for the tabs of the wear slots. */
26
37
  slots?: Record<string, SlotWords>;
27
- /** The sheet's title. Default "Looks". */
38
+ /** The sheet's title. Default: `words.title` ("Looks"). */
28
39
  title?: string;
40
+ /** The game's words for the two sheets, over the defaults (SHOP_WORDS): `{ buyTitle: (look) => look, sendExactly: "Pay exactly" }`. */
41
+ words?: Partial<ShopWords>;
29
42
  /** Draw the Looks key. Default true; false: the game calls `open()` from its own key. */
30
43
  key?: boolean;
31
44
  /** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
@@ -41,7 +54,10 @@ export type MountedShop = {
41
54
  /** Opens the sheet on the looks. */
42
55
  open(): void;
43
56
  close(): void;
44
- /** Opens the sheet on Buy for one look (the game's own "Buy" elsewhere). */
57
+ /**
58
+ * 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
59
+ * without a sign-in calls `onSignInCancel` and opens nothing.
60
+ */
45
61
  buy(lookId: string): void;
46
62
  /** Draws the looks again (after the player signed in, or wore something). */
47
63
  refresh(): void;
@@ -4,15 +4,21 @@
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. The sheet is a <dialog>: it rises from
9
- // the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay sheet puts the QR code beside the words.
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
  import { ApiError, createApi } from "../../admin/panel/api.js";
11
16
  import { h, releaseFocus } from "../../admin/panel/dom.js";
12
17
  import { icon } from "../../admin/panel/icons.js";
13
18
  import { checkLooks, wearOf } from "../../looks/look.js";
14
19
  import { looksView, slotsOf } from "./looks.js";
15
20
  import { payPanel } from "./pay.js";
21
+ import { shopWords } from "./words.js";
16
22
  export function mountShop(root, options) {
17
23
  const looks = checkLooks(options.looks);
18
24
  const api = createApi(options.base ?? "/api/orders", options.fetch);
@@ -21,11 +27,13 @@ export function mountShop(root, options) {
21
27
  let stopPay = null;
22
28
  let destroyed = false;
23
29
  const cleanup = [];
30
+ const words = shopWords(options.words);
31
+ const sheetTitle = options.title ?? words.title;
24
32
  const title = h("h2", { class: "n133s-title", tabindex: "-1" });
25
- const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
33
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
26
34
  const body = h("div", { class: "n133s-body" });
27
- const dialog = h("dialog", { class: "n133s-sheet", "aria-label": options.title ?? "Looks" }, h("header", { class: "n133s-head" }, title, closeKey), body);
28
- 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" }, options.title ?? "Looks"));
35
+ const dialog = h("dialog", { class: "n133s-sheet", "aria-label": sheetTitle }, h("header", { class: "n133s-head" }, title, closeKey), body);
36
+ 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));
29
37
  const wrap = h("div", { class: "n133-shop" }, key, dialog);
30
38
  root.append(wrap);
31
39
  // The theme: the game's word, or the page's (data-n133-theme), or the device's setting.
@@ -96,7 +104,7 @@ export function mountShop(root, options) {
96
104
  function showLooks() {
97
105
  if (destroyed)
98
106
  return;
99
- show(options.title ?? "Looks", looksView({
107
+ show(sheetTitle, looksView({
100
108
  looks,
101
109
  owned: ownedNow(),
102
110
  signedIn: options.signedIn(),
@@ -111,9 +119,10 @@ export function mountShop(root, options) {
111
119
  showLooks();
112
120
  }
113
121
  : undefined,
114
- buy: (l) => void buy(l),
122
+ buy: (l) => void buy(l, false),
115
123
  picture: options.picture,
116
124
  slots: options.slots,
125
+ words,
117
126
  tab,
118
127
  onTab: (s) => {
119
128
  tab = s;
@@ -122,14 +131,18 @@ export function mountShop(root, options) {
122
131
  },
123
132
  }));
124
133
  }
125
- const back = (message, extra) => h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() }, "Back to looks"));
126
- async function buy(look) {
134
+ const back = (message, extra) => 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));
135
+ /**
136
+ * `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.
137
+ * `ended`: the server said the session ended (401): sign in again whatever `signedIn()` says.
138
+ */
139
+ async function buy(look, game, ended = false) {
127
140
  if (destroyed)
128
141
  return;
129
142
  tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
130
- if (!options.signedIn()) {
143
+ if (ended || !options.signedIn()) {
131
144
  if (!options.signIn)
132
- return show(`Buy ${look.name}`, back("To buy a look, sign in with Nano first: your looks belong to your Nano address."));
145
+ return show(words.buyTitle(look.name), back(words.signInFirst));
133
146
  let ok = false;
134
147
  try {
135
148
  ok = await options.signIn();
@@ -137,11 +150,15 @@ export function mountShop(root, options) {
137
150
  catch {
138
151
  ok = false;
139
152
  }
140
- if (!ok || !options.signedIn())
141
- return showLooks();
153
+ if (!ok || !options.signedIn()) {
154
+ if (!game)
155
+ return showLooks();
156
+ close();
157
+ return options.onSignInCancel?.(look.id, look);
158
+ }
142
159
  }
143
160
  const box = h("div", { class: "n133s-turnstile", hidden: true });
144
- show(`Buy ${look.name}`, h("div", {}, h("p", { class: "n133s-status" }, "Making your order…"), box));
161
+ show(words.buyTitle(look.name), h("div", {}, h("p", { class: "n133s-status" }, words.making), box));
145
162
  try {
146
163
  const turnstile = options.personCheck ? await options.personCheck(box) : null;
147
164
  const order = await api.post("", { lookId: look.id, turnstile });
@@ -150,19 +167,23 @@ export function mountShop(root, options) {
150
167
  const pay = payPanel(order, {
151
168
  lookName: look.name,
152
169
  check: (id) => api.post(`/${id}/check`),
153
- errorText: (e) => (e instanceof ApiError ? e.message : null),
170
+ errorText: (e) => (e instanceof ApiError ? words.refusal(e.message, e.status) : null),
171
+ words,
154
172
  onPaid: () => {
155
173
  bought.add(look.id);
156
174
  options.onOwned?.(look.id, look);
157
175
  },
158
176
  });
159
- show(`Buy ${look.name}`, pay.node);
177
+ show(words.buyTitle(look.name), pay.node);
160
178
  stopPay = pay.stop;
161
179
  }
162
180
  catch (e) {
163
- const message = e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : "The order did not start. Try again.";
164
- const again = e instanceof ApiError && e.status === 401 && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look) }, "Sign in") : null;
165
- show(`Buy ${look.name}`, back(message, again));
181
+ const message = e instanceof ApiError ? words.refusal(e.message, e.status) : e instanceof Error && e.message ? words.refusal(e.message, 0) : words.orderFailed;
182
+ const gone = e instanceof ApiError && e.status === 401;
183
+ if (gone)
184
+ options.onSessionEnd?.();
185
+ 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;
186
+ show(words.buyTitle(look.name), back(message, again));
166
187
  }
167
188
  }
168
189
  return {
@@ -170,8 +191,14 @@ export function mountShop(root, options) {
170
191
  close,
171
192
  buy(lookId) {
172
193
  const look = looks.find((l) => l.id === lookId);
173
- if (look)
174
- void buy(look);
194
+ // 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.
195
+ if (!look)
196
+ return;
197
+ const go = () => void buy(look, true);
198
+ if (typeof requestAnimationFrame === "function" && !globalThis.document?.hidden)
199
+ requestAnimationFrame(go);
200
+ else
201
+ setTimeout(go, 0);
175
202
  },
176
203
  refresh() {
177
204
  if (isOpen() && body.querySelector(".n133s-looks-view"))
@@ -1,4 +1,5 @@
1
1
  import type { PayView } from "../handler.js";
2
+ import { type ShopWords } from "./words.js";
2
3
  export declare const PAY_TIMING: {
3
4
  readonly pollMs: 5000;
4
5
  readonly checkForMs: number;
@@ -14,6 +15,8 @@ export type PayOptions = {
14
15
  errorText?(e: unknown): string | null;
15
16
  /** The time now in ms (a test gives its own). */
16
17
  now?: () => number;
18
+ /** The game's words (./words.ts): the keys it leaves out keep the defaults. */
19
+ words?: Partial<ShopWords>;
17
20
  };
18
21
  export type PayPanel = {
19
22
  node: HTMLElement;
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // TEST MODE: the server sends no address and no link, and this sheet shows a large "TEST MODE: DO NOT PAY" stamp where the QR code would be, with the
5
5
  // wallet key off. Never change that: a payment to an address that nobody holds the key for is lost for good. The sheet decides it from the ORDER (its
6
- // `test`, a missing `uri` or a missing `address`), never from a setting of the page.
7
- import { copy, h } from "../../admin/panel/dom.js";
6
+ // `test`, a missing `uri` or a missing `address`), never from a setting of the page. The stamp's words are not among the game's words (./words.ts).
7
+ import { h } from "../../admin/panel/dom.js";
8
8
  import { usdText } from "../../looks/look.js";
9
9
  import { qrSvg } from "./qr.js";
10
+ import { priceLine, shopWords } from "./words.js";
10
11
  export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 };
11
12
  /** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
12
13
  export const isTestOrder = (o) => o.test || !o.uri || !o.address;
@@ -17,27 +18,40 @@ export function payPanel(start, options) {
17
18
  let timer;
18
19
  let clockTimer;
19
20
  let checkingUntil = 0;
20
- const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
21
- const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, "Check again");
22
- const left = h("span", { class: "n133s-time-left" });
23
- const amount = h("div", { class: "n133s-amount" }, h("strong", {}, `${order.xno} XNO`), h("span", {}, ` = ${usdText(Number(order.usd))}`));
21
+ const w = shopWords(options.words);
22
+ const test = isTestOrder(order);
23
+ /** The order's state. In test mode nothing is checked, so it has nothing to say and takes no room. */
24
+ const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite", hidden: test });
25
+ const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, w.checkAgain);
26
+ /** The time left, live only: in test mode nothing is paid, so nothing waits for the end. */
27
+ const ends = h("p", { class: "n133s-fine n133s-time-left", hidden: test });
28
+ const amount = h("div", { class: "n133s-amount" }, test ? null : h("span", { class: "n133s-exact" }, w.sendExactly), h("strong", {}, w.amount(order.xno)), h("span", {}, w.usd(usdText(Number(order.usd)))));
29
+ const copyKey = (value) => {
30
+ const key = h("button", { class: "n133s-btn n133s-small", type: "button" }, w.copy);
31
+ key.addEventListener("click", async () => {
32
+ try {
33
+ await navigator.clipboard.writeText(value);
34
+ key.textContent = w.copied;
35
+ }
36
+ catch {
37
+ key.textContent = w.copyFailed;
38
+ }
39
+ setTimeout(() => (key.textContent = w.copy), 1500);
40
+ });
41
+ return key;
42
+ };
24
43
  let payArea;
25
44
  /** The main key (the wallet link; off in test mode): it stands in a foot that stays in view, so a phone on its side never hides it. */
26
45
  let wallet;
27
- const test = isTestOrder(order);
28
46
  if (test) {
29
- payArea = h("div", { class: "n133s-test-pay" }, h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })), h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")), h("p", { class: "n133s-fine" }, "This game is in test mode, so nothing can be paid here. To sell for real, the creator sets their own receive address and turns test mode off."));
30
- wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, "Open wallet (off in test mode)");
47
+ payArea = h("div", { class: "n133s-test-pay" }, h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })), h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")), h("p", { class: "n133s-fine" }, w.testNote));
48
+ wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, w.walletOff);
31
49
  }
32
50
  else {
33
- const copyAmount = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
34
- copyAmount.addEventListener("click", () => copy(order.xno, copyAmount));
35
- const copyAddress = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
36
- copyAddress.addEventListener("click", () => copy(order.address, copyAddress));
37
- payArea = h("div", { class: "n133s-live-pay" }, h("div", { class: "n133s-qr" }, qrSvg(order.uri, `QR code to pay ${order.xno} XNO`)), h("details", { class: "n133s-by-hand" }, h("summary", {}, "Enter it by hand"), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "Amount"), h("code", {}, `${order.xno} XNO`), copyAmount), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "To"), h("code", { class: "n133s-address" }, order.address), copyAddress), h("p", { class: "n133s-fine" }, "Send exactly this amount, with every digit. Another amount does not match this order.")));
38
- wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri }, "Open wallet");
51
+ payArea = h("div", { class: "n133s-live-pay" }, h("div", { class: "n133s-qr" }, qrSvg(order.uri, w.qrLabel(order.xno))), h("details", { class: "n133s-by-hand" }, h("summary", {}, w.byHand), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.amountLabel), h("code", {}, w.amount(order.xno)), copyKey(order.xno)), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.toLabel), h("code", { class: "n133s-address" }, order.address), copyKey(order.address)), h("p", { class: "n133s-fine" }, w.byHandNote)));
52
+ wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri }, w.wallet);
39
53
  }
40
- const node = h("div", { class: `n133s-pay${test ? " n133s-is-test" : ""}` }, h("div", { class: "n133s-pay-main" }, h("p", { class: "n133s-pay-what" }, `${options.lookName}: a look for your character. It changes how you look, not how you play.`), amount, payArea), h("div", { class: "n133s-pay-side" }, h("p", { class: "n133s-fine" }, "This order ends in ", left, "."), status, again, h("p", { class: "n133s-fine n133s-prices" }, `Price in XNO from ${order.prices.join(", ")}. Powered by CoinGecko.`), h("div", { class: "n133s-foot" }, wallet)));
54
+ const node = h("div", { class: `n133s-pay${test ? " n133s-is-test" : ""}` }, h("div", { class: "n133s-pay-main" }, h("p", { class: "n133s-pay-what" }, w.lookLine(options.lookName)), amount, payArea), h("div", { class: "n133s-pay-side" }, ends, status, again, h("p", { class: "n133s-fine n133s-prices" }, priceLine(w, order.prices)), h("div", { class: "n133s-foot" }, wallet)));
41
55
  const stop = () => {
42
56
  stopped = true;
43
57
  if (timer)
@@ -50,7 +64,7 @@ export function payPanel(start, options) {
50
64
  const ms = Math.max(0, order.expiresAt - now());
51
65
  const m = Math.floor(ms / 60_000);
52
66
  const s = Math.floor((ms % 60_000) / 1000);
53
- left.textContent = `${m}:${String(s).padStart(2, "0")}`;
67
+ ends.textContent = w.endsIn(`${m}:${String(s).padStart(2, "0")}`);
54
68
  };
55
69
  tick();
56
70
  clockTimer = setInterval(tick, 1000);
@@ -58,7 +72,7 @@ export function payPanel(start, options) {
58
72
  order = o;
59
73
  if (o.status === "paid") {
60
74
  stop();
61
- payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, "Paid. Thank you!"), h("p", {}, `${options.lookName} is yours now.`)));
75
+ payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, w.paid), h("p", {}, w.paidLine(options.lookName))));
62
76
  wallet.hidden = true;
63
77
  status.textContent = "";
64
78
  again.hidden = true;
@@ -67,13 +81,13 @@ export function payPanel(start, options) {
67
81
  }
68
82
  if (o.status === "paid_late") {
69
83
  stop();
70
- status.textContent = "This payment came after the order ended. The look was not given. The creator sees it and can send it back.";
84
+ status.textContent = w.paidLate;
71
85
  again.hidden = true;
72
86
  return true;
73
87
  }
74
88
  if (o.status === "expired") {
75
89
  stop();
76
- status.textContent = "This order ended. If you already paid, wait here: a payment sent in time still counts.";
90
+ status.textContent = w.expired;
77
91
  again.hidden = false;
78
92
  return false;
79
93
  }
@@ -87,15 +101,15 @@ export function payPanel(start, options) {
87
101
  if (show(o))
88
102
  return;
89
103
  if (o.status === "open")
90
- status.textContent = test ? "Test mode: the game does not wait for a payment." : "Waiting for your payment…";
104
+ status.textContent = test ? "" : w.waiting;
91
105
  }
92
106
  catch (e) {
93
- status.textContent = options.errorText?.(e) ?? "No connection. Trying again…";
107
+ status.textContent = options.errorText?.(e) ?? w.noConnection;
94
108
  }
95
109
  if (now() < checkingUntil)
96
110
  timer = setTimeout(poll, PAY_TIMING.pollMs);
97
111
  else if (!stopped) {
98
- status.textContent = "Still waiting. Already paid? Press Check again.";
112
+ status.textContent = w.stillWaiting;
99
113
  again.hidden = false;
100
114
  }
101
115
  }
@@ -107,9 +121,8 @@ export function payPanel(start, options) {
107
121
  void poll();
108
122
  }
109
123
  again.addEventListener("click", startChecking);
124
+ // Test mode makes no checks: nothing can be paid, so nothing is waited for.
110
125
  if (!test)
111
126
  startChecking();
112
- else
113
- status.textContent = "Test mode: the game does not wait for a payment.";
114
127
  return { node, stop };
115
128
  }
@@ -0,0 +1,89 @@
1
+ import type { Hat } from "../../looks/look.js";
2
+ export type ShopWords = {
3
+ /** The sheet's title, the Looks key's word and the sheet's name for a screen reader. The option `title` wins over it. */
4
+ title: string;
5
+ /** The close key's name for a screen reader. */
6
+ close: string;
7
+ /** The tabs' name for a screen reader. */
8
+ kinds: string;
9
+ /** The outfits' tab. */
10
+ outfits: string;
11
+ /** The tab of a wear slot of the game's own ("shovel"). The option `slots` wins over it. */
12
+ slotTab: (slot: string) => string;
13
+ /** The plain choice of a wear slot ("No shovel"). The option `slots` wins over it. */
14
+ slotPlain: (slot: string) => string;
15
+ /** The line under an outfit: what it changes (its hat, or the colour only). */
16
+ outfitLine: (hat: Hat | null) => string;
17
+ /** The line under an item of a wear slot. */
18
+ itemLine: (slot: string) => string;
19
+ /** Added to the line of a free look, and the plain choice's line. */
20
+ free: string;
21
+ /** Added to the line of a look the player owns. */
22
+ yours: string;
23
+ wear: string;
24
+ wearing: string;
25
+ /** The Buy key, with the look's price ("$0.25"). */
26
+ buy: (price: string) => string;
27
+ /** Under the list. */
28
+ looksNote: string;
29
+ /** Under the list and at Buy, while nobody is signed in. */
30
+ signInFirst: string;
31
+ /** The title of Buy and of the pay sheet. */
32
+ buyTitle: (look: string) => string;
33
+ making: string;
34
+ /** An order that did not start, with no words from the server. */
35
+ orderFailed: string;
36
+ /** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
37
+ refusal: (text: string, status: number) => string;
38
+ /** The key back to the looks. */
39
+ back: string;
40
+ /** The key that signs in again after "Sign in first." (401). */
41
+ signIn: string;
42
+ /** The line under the title. */
43
+ lookLine: (look: string) => string;
44
+ /** The amount in XNO, large ("0.25 XNO"). */
45
+ amount: (xno: string) => string;
46
+ /** The amount in dollars, beside it ("= $0.25"). */
47
+ usd: (usd: string) => string;
48
+ /** Above the amount, live only: the player must send this exact amount. */
49
+ sendExactly: string;
50
+ /** Under the stamp in test mode: nothing can be paid here. */
51
+ testNote: string;
52
+ /** The wallet key in test mode, off, with its reason. */
53
+ walletOff: string;
54
+ /** The wallet key (live). */
55
+ wallet: string;
56
+ /** The QR code's name for a screen reader. */
57
+ qrLabel: (xno: string) => string;
58
+ /** The fold with the amount and the address to type by hand. */
59
+ byHand: string;
60
+ amountLabel: string;
61
+ /** The address's label in the fold. */
62
+ toLabel: string;
63
+ /** In the fold: another amount does not match the order. */
64
+ byHandNote: string;
65
+ copy: string;
66
+ copied: string;
67
+ copyFailed: string;
68
+ /** The time left (live only): the order ends then. */
69
+ endsIn: (time: string) => string;
70
+ waiting: string;
71
+ noConnection: string;
72
+ stillWaiting: string;
73
+ checkAgain: string;
74
+ paid: string;
75
+ paidLine: (look: string) => string;
76
+ /** A payment after the order ended: it gives no look, and the creator sends it back. */
77
+ paidLate: string;
78
+ /** The order ended: a payment sent in time still counts. */
79
+ expired: string;
80
+ /** The price's sources ("CoinGecko", "Kraken", "KuCoin"). "Powered by CoinGecko" is added when CoinGecko is one and the words leave it out. */
81
+ prices: (sources: readonly string[]) => string;
82
+ };
83
+ /** CoinGecko's words: its API terms ask for them wherever its price shows. */
84
+ export declare const COINGECKO_CREDIT = "Powered by CoinGecko";
85
+ export declare const SHOP_WORDS: ShopWords;
86
+ /** The game's words over the defaults. A key the game leaves out, or gives as undefined, keeps its default. */
87
+ export declare function shopWords(over?: Partial<ShopWords>): ShopWords;
88
+ /** The price's line: the game's words, with "Powered by CoinGecko" added when CoinGecko is a source and the words leave it out. */
89
+ export declare function priceLine(words: ShopWords, sources: readonly string[]): string;
@@ -0,0 +1,79 @@
1
+ // Every word that the shop's sheets show: the looks sheet, Buy and the pay sheet. A game gives `words` to `mountShop` (or to `looksView` and `payPanel`)
2
+ // 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 value.
3
+ //
4
+ // Two things a game cannot change, because a player's money depends on them: the stamp "TEST MODE: DO NOT PAY" (it is not a key), and the words
5
+ // "Powered by CoinGecko" while CoinGecko is one of the price feeds (CoinGecko's API terms ask for them: `priceLine` adds them when `prices` leaves
6
+ // them out).
7
+ //
8
+ // The defaults are short on purpose (Wes, 2026-11-01: fewer words): the pay sheet shows at most 30 words, and no sentence has more than 10.
9
+ const HATS = { cap: "with a cap", beanie: "with a beanie", crown: "with a crown", tophat: "with a top hat" };
10
+ const spaced = (slot) => slot.replace(/-/g, " ");
11
+ /** CoinGecko's words: its API terms ask for them wherever its price shows. */
12
+ export const COINGECKO_CREDIT = "Powered by CoinGecko";
13
+ export const SHOP_WORDS = {
14
+ title: "Looks",
15
+ close: "Close",
16
+ kinds: "Kinds of looks",
17
+ outfits: "Outfits",
18
+ slotTab: (slot) => spaced(slot.charAt(0).toUpperCase() + slot.slice(1)),
19
+ slotPlain: (slot) => `No ${spaced(slot)}`,
20
+ outfitLine: (hat) => (hat ? HATS[hat] : "colour only"),
21
+ itemLine: (slot) => spaced(slot),
22
+ free: "free",
23
+ yours: "yours",
24
+ wear: "Wear",
25
+ wearing: "Wearing",
26
+ buy: (price) => `Buy ${price}`,
27
+ looksNote: "Looks never change play. Pay in Nano, straight to the creator.",
28
+ signInFirst: "Sign in first to buy a look.",
29
+ buyTitle: (look) => `Buy ${look}`,
30
+ making: "Making your order…",
31
+ orderFailed: "The order did not start. Try again.",
32
+ refusal: (text) => text,
33
+ back: "Back",
34
+ signIn: "Sign in",
35
+ lookLine: () => "It never changes play.",
36
+ amount: (xno) => `${xno} XNO`,
37
+ usd: (usd) => `= ${usd}`,
38
+ sendExactly: "Send exactly",
39
+ testNote: "Nothing can be paid here.",
40
+ walletOff: "Wallet off: test mode",
41
+ wallet: "Open wallet",
42
+ qrLabel: (xno) => `QR code to pay ${xno} XNO`,
43
+ byHand: "Enter by hand",
44
+ amountLabel: "Amount",
45
+ toLabel: "To",
46
+ byHandNote: "Another amount does not match.",
47
+ copy: "Copy",
48
+ copied: "Copied",
49
+ copyFailed: "Copy failed",
50
+ endsIn: (time) => `Ends in ${time}.`,
51
+ waiting: "Waiting for payment…",
52
+ noConnection: "No connection. Trying again…",
53
+ stillWaiting: "Still waiting. Already paid? Press Check again.",
54
+ checkAgain: "Check again",
55
+ paid: "Paid. Thank you!",
56
+ paidLine: (look) => `${look} is yours.`,
57
+ paidLate: "Paid after the order ended, so no look. The creator sends it back.",
58
+ expired: "This order ended. Paid in time? Wait here: it still counts.",
59
+ prices: (sources) => {
60
+ const others = sources.filter((s) => s !== "CoinGecko");
61
+ const credit = sources.includes("CoinGecko") ? ` ${COINGECKO_CREDIT}.` : "";
62
+ return others.length ? `Price: ${others.join(", ")}.${credit}` : credit.trim();
63
+ },
64
+ };
65
+ /** The game's words over the defaults. A key the game leaves out, or gives as undefined, keeps its default. */
66
+ export function shopWords(over) {
67
+ const out = { ...SHOP_WORDS };
68
+ for (const [k, v] of Object.entries(over ?? {}))
69
+ if (v !== undefined && k in SHOP_WORDS)
70
+ out[k] = v;
71
+ return out;
72
+ }
73
+ /** The price's line: the game's words, with "Powered by CoinGecko" added when CoinGecko is a source and the words leave it out. */
74
+ export function priceLine(words, sources) {
75
+ const line = words.prices(sources).trim();
76
+ if (!sources.includes("CoinGecko") || line.includes(COINGECKO_CREDIT))
77
+ return line;
78
+ return `${line}${line ? " " : ""}${COINGECKO_CREDIT}.`;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,3 +12,9 @@ export function xnoText(raw: string): string {
12
12
 
13
13
  /** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
14
14
  export const isRawText = (s: unknown): s is string => typeof s === "string" && /^[0-9]{1,39}$/.test(s);
15
+
16
+ /** Dollars as plain decimals, as an order keeps them: at least 2 decimals and at most 4, "0.25", "1.00", "0.005", "0.0125". */
17
+ export const usdDecimals = (usd: number): string => usd.toFixed(4).replace(/(\.\d\d\d*?)0+$/, "$1");
18
+
19
+ /** "$0.25", "$1.00", "$0.005". */
20
+ export const usdText = (usd: number): string => `$${usdDecimals(usd)}`;
@@ -1,5 +1,7 @@
1
1
  // A tiny helper to build page elements without innerHTML (safe for any text, and fine under a strict CSP). The panel runs in a browser.
2
2
 
3
+ import { usdText } from "../amount.js";
4
+
3
5
  type Attrs = Record<string, string | number | boolean | undefined | ((e: Event) => void)>;
4
6
  type Child = Node | string | null | undefined | false;
5
7
 
@@ -67,5 +69,5 @@ export function ago(ms: number, now = Date.now()): string {
67
69
  /** "3 orders", "1 order" */
68
70
  export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
69
71
 
70
- /** "$1.25" from cents. */
71
- export const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
72
+ /** "$1.25" from cents, "$0.005" from half a cent. */
73
+ export const dollars = (cents: number): string => usdText(cents / 100);
@@ -503,7 +503,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
503
503
  sync();
504
504
  } });
505
505
 
506
- const priceInput = h("input", { class: "input input-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e: Event) => {
506
+ const priceInput = h("input", { class: "input input-num", type: "number", step: 0.0001, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e: Event) => {
507
507
  const v = (e.target as HTMLInputElement).value;
508
508
  l.priceUsd = v === "" ? Number.NaN : Number(v);
509
509
  sync();
@@ -2,7 +2,7 @@
2
2
  // here comes from the game's own database (the admin summary) and from looks.json; nothing is sent anywhere.
3
3
 
4
4
  import type { LooksDiff } from "../../looks/change.js";
5
- import { ID_PATTERN, type Look } from "../../looks/look.js";
5
+ import { ID_PATTERN, usdText, type Look } from "../../looks/look.js";
6
6
 
7
7
  export type ShelfOrder = { lookId: string; status: string; usd: string };
8
8
  export type ShelfPlayer = { looks: { id: string }[] };
@@ -20,8 +20,8 @@ export type LookStats = {
20
20
 
21
21
  const none = (): LookStats => ({ owners: 0, paid: 0, paidCents: 0, late: 0 });
22
22
 
23
- /** "0.25" is 25 cents. */
24
- export const centsOf = (usd: string): number => Math.round(Number(usd) * 100) || 0;
23
+ /** "0.25" is 25 cents; "0.005" is half a cent (cents keep 2 decimals: a price has at most 4). */
24
+ export const centsOf = (usd: string): number => Math.round(Number(usd) * 10_000) / 100 || 0;
25
25
 
26
26
  /** The stats for each look id that appears in the orders or in a player's looks. */
27
27
  export function shelfStats(orders: readonly ShelfOrder[], players: readonly ShelfPlayer[]): Map<string, LookStats> {
@@ -31,7 +31,7 @@ export function shelfStats(orders: readonly ShelfOrder[], players: readonly Shel
31
31
  if (o.status === "paid") {
32
32
  const s = of(o.lookId);
33
33
  s.paid++;
34
- s.paidCents += centsOf(o.usd);
34
+ s.paidCents = Math.round((s.paidCents + centsOf(o.usd)) * 100) / 100;
35
35
  } else if (o.status === "paid_late") of(o.lookId).late++;
36
36
  }
37
37
  for (const p of players) for (const l of new Set(p.looks.map((x) => x.id))) of(l).owners++;
@@ -53,7 +53,7 @@ export function changeText(d: LooksDiff): string {
53
53
  /** What to say when a player's money is in a look: "3 sold, $0.75, 3 owners", or "Not sold yet". */
54
54
  export function soldText(s: LookStats | undefined): string {
55
55
  if (!s || (!s.paid && !s.owners)) return "Not sold yet";
56
- return `${s.paid} sold, $${(s.paidCents / 100).toFixed(2)}, ${plural(s.owners, "owner")}`;
56
+ return `${s.paid} sold, ${usdText(s.paidCents / 100)}, ${plural(s.owners, "owner")}`;
57
57
  }
58
58
 
59
59
  /**
@@ -76,7 +76,7 @@ export function aiPrompt(text: string, diff: LooksDiff, owned: readonly string[]
76
76
  diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
77
77
  "",
78
78
  `Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
79
- `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
79
+ `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 4 decimals (0.005 is half a cent), never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
80
80
  "",
81
81
  "looks.json:",
82
82
  "```json",