@nano133/nanoterra 0.8.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/README.md +390 -6
- package/ai/SPEC.md +71 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +8 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +107 -0
- package/dist/player-signin/panel/view.d.ts +48 -0
- package/dist/player-signin/panel/view.js +363 -0
- package/dist/player-signin/panel/words.d.ts +103 -0
- package/dist/player-signin/panel/words.js +111 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +9 -0
- package/dist/shop/panel/index.js +14 -0
- package/dist/shop/panel/looks.d.ts +33 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +68 -0
- package/dist/shop/panel/mount.js +221 -0
- package/dist/shop/panel/pay.d.ts +27 -0
- package/dist/shop/panel/pay.js +128 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +13 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +129 -0
- package/src/player-signin/panel/style.css +478 -0
- package/src/player-signin/panel/view.ts +433 -0
- package/src/player-signin/panel/words.ts +210 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +19 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +280 -0
- package/src/shop/panel/pay.ts +192 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +538 -0
- package/src/shop/panel/words.ts +172 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Talking to Nano nodes, with failover (the game starter's src/worker/nodes.ts). The shop holds no key and sends nothing: it only READS the ledger, to see
|
|
2
|
+
// whether the creator's address received a payment.
|
|
3
|
+
//
|
|
4
|
+
// The order of the list (`nodesFor`, the starter's defaults):
|
|
5
|
+
// 1. Nanswap Nodes, when the game gives its API key (the starter's secret NODE_API_KEY; a free key: https://nanswap.com/nano-node).
|
|
6
|
+
// 2. The game's own list of node addresses (the starter's setting NANO_NODES), or else the public nodes that need no key. Those are run by volunteers,
|
|
7
|
+
// with no promise of uptime.
|
|
8
|
+
// A node that does not answer, answers with an HTTP error or answers with an error is skipped, and the next one is asked. When none answers, the check
|
|
9
|
+
// fails closed: nothing is credited, and the order waits.
|
|
10
|
+
|
|
11
|
+
/** The `fetch` the shop uses (a test gives its own). */
|
|
12
|
+
export type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
export type NanoNode = { name: string; url: string; headers?: Record<string, string> };
|
|
15
|
+
|
|
16
|
+
/** The keyed provider. Its key is a secret: the game reads it from its secret store and passes it, never writes it in code. */
|
|
17
|
+
export const KEYED_NODE = { name: "Nanswap Nodes", url: "https://nodes.nanswap.com/XNO", keyHeader: "nodes-api-key" } as const;
|
|
18
|
+
|
|
19
|
+
/** Public nodes with no key (from the community list at publicnodes.somenano.com, read 2026-10-21). */
|
|
20
|
+
export const PUBLIC_NODES: readonly NanoNode[] = [
|
|
21
|
+
{ name: "rpc.nano.to", url: "https://rpc.nano.to" },
|
|
22
|
+
{ name: "Rainstorm", url: "https://rainstorm.city/api" },
|
|
23
|
+
{ name: "NanOslo", url: "https://nanoslo.0x.no/proxy" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An optional setting or secret: its value without spaces around it, or undefined when it is not set. A setup page that needs a value in every field
|
|
28
|
+
* (Cloudflare's "Deploy" page) gets "none", "skip", "off" or "-" (in any case), which also mean "not set".
|
|
29
|
+
*/
|
|
30
|
+
export function settingValue(v?: string | null): string | undefined {
|
|
31
|
+
const s = (v ?? "").trim();
|
|
32
|
+
return s && !/^(none|skip|off|-)$/i.test(s) ? s : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The nodes to ask, in order. `NODE_API_KEY`: the keyed provider's key (it goes first). `NANO_NODES`: the game's own node addresses, separated by
|
|
37
|
+
* commas, instead of the public list: https only, and plain http only for a node on this computer (local development with a mock node). The names are
|
|
38
|
+
* the game starter's, so `nodesFor(env)` works as it is.
|
|
39
|
+
*/
|
|
40
|
+
export function nodesFor(settings: { NODE_API_KEY?: string | null; NANO_NODES?: string | null } = {}): NanoNode[] {
|
|
41
|
+
const list: NanoNode[] = [];
|
|
42
|
+
const key = settingValue(settings.NODE_API_KEY);
|
|
43
|
+
if (key) list.push({ name: KEYED_NODE.name, url: KEYED_NODE.url, headers: { [KEYED_NODE.keyHeader]: key } });
|
|
44
|
+
const own = (settings.NANO_NODES ?? "")
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter((s) => /^https:\/\/[^\s]+$/.test(s) || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?(\/[^\s]*)?$/.test(s));
|
|
48
|
+
list.push(...(own.length ? own.map((url) => ({ name: new URL(url).host, url })) : PUBLIC_NODES.map((n) => ({ ...n }))));
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class NodeDown extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "NodeDown";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Nodes that failed lately are asked last for a short time (in this copy of the server only). */
|
|
60
|
+
const restUntil = new Map<string, number>();
|
|
61
|
+
const REST_MS = 30_000;
|
|
62
|
+
|
|
63
|
+
export type RpcOptions = { fetch?: Fetch; timeoutMs?: number; now?: () => number };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sends one RPC call to the first node that answers. Returns its answer, or null when the node says the thing does not exist ("Account not found",
|
|
67
|
+
* "Block not found"). Throws NodeDown when no node answers.
|
|
68
|
+
*/
|
|
69
|
+
export async function rpc(nodes: readonly NanoNode[], body: Record<string, unknown>, o: RpcOptions = {}): Promise<{ node: string; answer: Record<string, any> | null }> {
|
|
70
|
+
if (!nodes.length) throw new NodeDown("no Nano node is set");
|
|
71
|
+
const fetchFn: Fetch = o.fetch ?? ((input, init) => fetch(input, init));
|
|
72
|
+
const now = o.now ?? Date.now;
|
|
73
|
+
const resting = (n: NanoNode) => (restUntil.get(n.url) ?? 0) > now();
|
|
74
|
+
const order = [...nodes.filter((n) => !resting(n)), ...nodes.filter(resting)];
|
|
75
|
+
const why: string[] = [];
|
|
76
|
+
for (const node of order) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetchFn(node.url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "nano-game-starter", ...node.headers },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? 5000),
|
|
84
|
+
});
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
let answer: unknown;
|
|
87
|
+
try {
|
|
88
|
+
answer = JSON.parse(text);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`HTTP ${res.status}, not JSON`);
|
|
91
|
+
}
|
|
92
|
+
if (!answer || typeof answer !== "object") throw new Error(`HTTP ${res.status}, not an object`);
|
|
93
|
+
const error = (answer as { error?: unknown }).error;
|
|
94
|
+
if (error !== undefined) {
|
|
95
|
+
if (/not found/i.test(String(error))) {
|
|
96
|
+
restUntil.delete(node.url);
|
|
97
|
+
return { node: node.name, answer: null };
|
|
98
|
+
}
|
|
99
|
+
throw new Error(String(error).slice(0, 80));
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
102
|
+
restUntil.delete(node.url);
|
|
103
|
+
return { node: node.name, answer: answer as Record<string, any> };
|
|
104
|
+
} catch (e) {
|
|
105
|
+
restUntil.set(node.url, now() + REST_MS);
|
|
106
|
+
why.push(`${node.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new NodeDown(`no Nano node answered (${why.join("; ")})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** For tests: forget which nodes failed. */
|
|
113
|
+
export function forgetNodeHealth(): void {
|
|
114
|
+
restUntil.clear();
|
|
115
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Orders: the clean amount and its reservation (the game starter's src/worker/orders.ts; the writes are the store's, ./store.ts).
|
|
2
|
+
// DO NOT CHANGE THE MONEY RULES HERE without the tests that pin them (the conformance suite, test/shop-*.test.ts).
|
|
3
|
+
//
|
|
4
|
+
// The reservation (the same rule as the Nano Unlock WordPress plugin):
|
|
5
|
+
// - An order asks for its price, rounded up to 0.0001 XNO. While another held order has that exact amount, it asks for one step (0.0001 XNO) more, and so
|
|
6
|
+
// on: at most a tenth of the price more, and at most 99 steps. When every step is held, the player is asked to try again in a moment.
|
|
7
|
+
// - No two held orders can ever ask for the same amount (the store's unique rule), so one payment can only match one order. Two orders made at the same
|
|
8
|
+
// instant cannot both take an amount: the store refuses the second, and it moves on to the next step.
|
|
9
|
+
// - The amount stays held from the order's start to LATE_HOLD_MS after it expires, so a late payment is still recognised (as "paid late") and never counts
|
|
10
|
+
// for a newer order. A paid order frees its amount at once (its payment's hash is recorded, so that payment never counts again).
|
|
11
|
+
|
|
12
|
+
import { compareRaw, isClean, plusSteps, STEP_RAW } from "./amount.js";
|
|
13
|
+
import { CLOCK_SLACK_MS } from "./match.js";
|
|
14
|
+
import type { OrderStatus, ShopOrder, ShopStore } from "./store.js";
|
|
15
|
+
|
|
16
|
+
export const ORDER_TTL_MS = 15 * 60_000;
|
|
17
|
+
export const LATE_HOLD_MS = 60 * 60_000;
|
|
18
|
+
export const MAX_STEPS = 100;
|
|
19
|
+
export const MAX_EXTRA_PERCENT = 10;
|
|
20
|
+
/** One player can wait on this many orders at once. */
|
|
21
|
+
export const MAX_OPEN_PER_PLAYER = 3;
|
|
22
|
+
/** An order's id: 24 hex characters. */
|
|
23
|
+
export const ORDER_ID_PATTERN = /^[0-9a-f]{24}$/;
|
|
24
|
+
|
|
25
|
+
/** Every amount for this price is held by another order: ask the player to try again in a minute. */
|
|
26
|
+
export class ShopBusy extends Error {
|
|
27
|
+
constructor(message = "every amount for this price is held") {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ShopBusy";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How many amounts a price may use: the price and the steps above it. At least 1. */
|
|
34
|
+
export function stepsFor(price: string): number {
|
|
35
|
+
const base = BigInt(price) / STEP_RAW;
|
|
36
|
+
return Math.max(1, Math.min(MAX_STEPS, Number((base * BigInt(MAX_EXTRA_PERCENT)) / 100n) + 1));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The amounts an order of this price may ask for, smallest first. */
|
|
40
|
+
export function amountsFor(price: string): string[] {
|
|
41
|
+
if (!isClean(price)) throw new Error("the price must be a clean amount");
|
|
42
|
+
return Array.from({ length: stepsFor(price) }, (_, k) => plusSteps(price, k));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A new order id: 12 random bytes as hex. */
|
|
46
|
+
export const newOrderId = (): string => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(12)), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
|
|
48
|
+
export type ReserveInput = { player: string; lookId: string; usd: string; rate: number; price: string; visitor: string | null; now: number; ttlMs?: number };
|
|
49
|
+
|
|
50
|
+
/** Makes an order with the smallest free amount at or above `p.price` (a clean amount in raw). Throws ShopBusy when every allowed amount is held. */
|
|
51
|
+
export async function reserveOrder(store: ShopStore, p: ReserveInput): Promise<ShopOrder> {
|
|
52
|
+
const amounts = amountsFor(p.price);
|
|
53
|
+
await store.tidy(p.now);
|
|
54
|
+
const held = await store.heldAmounts(amounts);
|
|
55
|
+
const expiresAt = p.now + (p.ttlMs ?? ORDER_TTL_MS);
|
|
56
|
+
for (const amount of amounts) {
|
|
57
|
+
if (held.has(amount)) continue;
|
|
58
|
+
const order: ShopOrder = {
|
|
59
|
+
id: newOrderId(),
|
|
60
|
+
player: p.player,
|
|
61
|
+
lookId: p.lookId,
|
|
62
|
+
usd: p.usd,
|
|
63
|
+
rate: p.rate,
|
|
64
|
+
amount,
|
|
65
|
+
amountLock: amount,
|
|
66
|
+
status: "open",
|
|
67
|
+
createdAt: p.now,
|
|
68
|
+
expiresAt,
|
|
69
|
+
holdUntil: expiresAt + LATE_HOLD_MS,
|
|
70
|
+
hash: null,
|
|
71
|
+
payer: null,
|
|
72
|
+
paidAt: null,
|
|
73
|
+
visitor: p.visitor,
|
|
74
|
+
};
|
|
75
|
+
// False: another order took this amount since the read. Go on to the next step.
|
|
76
|
+
if (await store.insert(order)) return order;
|
|
77
|
+
}
|
|
78
|
+
throw new ShopBusy();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "paid" when the send was first seen while the order was open (with the clock allowance), else "paid_late". Every store uses this one rule. */
|
|
82
|
+
export const paidStatus = (order: Pick<ShopOrder, "expiresAt">, seenMs: number): "paid" | "paid_late" => (seenMs > order.expiresAt + CLOCK_SLACK_MS ? "paid_late" : "paid");
|
|
83
|
+
|
|
84
|
+
/** The status as the player reads it now: an open order past its time is "expired". */
|
|
85
|
+
export const statusAt = (o: Pick<ShopOrder, "status" | "expiresAt">, now: number): OrderStatus => (o.status === "open" && o.expiresAt < now ? "expired" : o.status);
|
|
86
|
+
|
|
87
|
+
/** The orders' smallest amount, or null (the scan's threshold). */
|
|
88
|
+
export const smallestAmount = (orders: readonly Pick<ShopOrder, "amount">[]): string | null => orders.map((o) => o.amount).sort(compareRaw)[0] ?? null;
|
|
89
|
+
|
|
90
|
+
/** The order as its player sees it (the starter's `orderView`). The pay screen adds the XNO text, the test mode, the address and the link. */
|
|
91
|
+
export function orderView(o: ShopOrder, now: number): { id: string; lookId: string; usd: string; amount: string; status: OrderStatus; createdAt: number; expiresAt: number } {
|
|
92
|
+
return { id: o.id, lookId: o.lookId, usd: o.usd, amount: o.amount, status: statusAt(o, now), createdAt: o.createdAt, expiresAt: o.expiresAt };
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// "@nano133/nanoterra/shop/panel": the Looks key and the shop's sheet, drawn in the browser. Plain DOM, no framework, no Three.js, no QR library.
|
|
2
|
+
//
|
|
3
|
+
// import { mountShop } from "@nano133/nanoterra/shop/panel";
|
|
4
|
+
// import "@nano133/nanoterra/shop/style.css";
|
|
5
|
+
// const shop = mountShop(document.getElementById("shop")!, { looks, owned: () => me.owned, signedIn: () => !!me.address, signIn, worn, wear, onOwned });
|
|
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
|
+
//
|
|
9
|
+
// The server part is "@nano133/nanoterra/shop" (the handler and the stores): it must not load this one.
|
|
10
|
+
|
|
11
|
+
export { mountShop } from "./mount.js";
|
|
12
|
+
export type { MountedShop, ShopMountOptions } from "./mount.js";
|
|
13
|
+
export { looksView, slotsOf } from "./looks.js";
|
|
14
|
+
export type { LooksViewOptions, SlotWords } from "./looks.js";
|
|
15
|
+
export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
|
|
16
|
+
export type { PayOptions, PayPanel } from "./pay.js";
|
|
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";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The looks sheet (the game starter's src/client/ui/looks.ts, with Echo Island's tabs for a game with wear slots): wear a look you own, or buy one.
|
|
2
|
+
// Every look is shown with its price before any step: nothing is random, and there are no loot boxes.
|
|
3
|
+
//
|
|
4
|
+
// A game that sells only outfits gets one list. A game with items of its own wear slots ("wear": "shovel") gets a tab for each slot, the outfits first;
|
|
5
|
+
// with `takeOff`, each item tab starts with its plain choice (free, "No charm").
|
|
6
|
+
|
|
7
|
+
import { h } from "../../admin/panel/dom.js";
|
|
8
|
+
import { OUTFIT, isOutfit, usdText, wearOf, type Look } from "../../looks/look.js";
|
|
9
|
+
import { shopWords, type ShopWords } from "./words.js";
|
|
10
|
+
|
|
11
|
+
/** A tab's words: its key's word, and its plain choice's name (an item slot's: "Plain shovel", "No charm"). */
|
|
12
|
+
export type SlotWords = { tab: string; plain?: string };
|
|
13
|
+
|
|
14
|
+
export type LooksViewOptions = {
|
|
15
|
+
looks: readonly Look[];
|
|
16
|
+
owned: ReadonlySet<string>;
|
|
17
|
+
signedIn: boolean;
|
|
18
|
+
/** Whether the player wears this look now. */
|
|
19
|
+
worn(look: Look): boolean;
|
|
20
|
+
wear(look: Look): void;
|
|
21
|
+
/** Takes off the item of a wear slot (back to the plain tool, or none). With it, each item tab starts with the plain choice. */
|
|
22
|
+
takeOff?: (slot: string) => void;
|
|
23
|
+
/** Whether nothing is worn in this slot (the plain choice is worn). Default: no look of the slot is worn. */
|
|
24
|
+
plainWorn?: (slot: string) => boolean;
|
|
25
|
+
buy(look: Look): void;
|
|
26
|
+
/** The game's own picture of a look (a canvas, an image), or null for the kit's swatch. */
|
|
27
|
+
picture?: (look: Look) => HTMLElement | null;
|
|
28
|
+
/** Words for the tabs, by wear slot ("outfit" and the game's own). Default: "Outfits", then each slot's name. */
|
|
29
|
+
slots?: Record<string, SlotWords>;
|
|
30
|
+
/** The tab that is open (a wear slot). Default: the first. */
|
|
31
|
+
tab?: string;
|
|
32
|
+
/** A tab was chosen: the sheet draws again with it. */
|
|
33
|
+
onTab?: (slot: string) => void;
|
|
34
|
+
/** The game's words (./words.ts): the keys it leaves out keep the defaults. */
|
|
35
|
+
words?: Partial<ShopWords>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The wear slots of a list, in the order they first show (the outfits first). */
|
|
39
|
+
export function slotsOf(looks: readonly Look[]): string[] {
|
|
40
|
+
const out: string[] = [OUTFIT];
|
|
41
|
+
for (const l of looks) if (!out.includes(wearOf(l))) out.push(wearOf(l));
|
|
42
|
+
return looks.some((l) => wearOf(l) === OUTFIT) ? out : out.slice(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function swatchOf(look: Look): HTMLElement {
|
|
46
|
+
const swatch = h("span", { class: "n133s-swatch", "aria-hidden": "true" });
|
|
47
|
+
if (isOutfit(look)) {
|
|
48
|
+
swatch.style.background = look.change.body;
|
|
49
|
+
if (look.change.hat) {
|
|
50
|
+
const hat = h("span", { class: `n133s-swatch-hat n133s-hat-${look.change.hat}` });
|
|
51
|
+
hat.style.background = look.change.hatColor ?? "#333";
|
|
52
|
+
swatch.append(hat);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
swatch.classList.add("n133s-swatch-item");
|
|
56
|
+
swatch.textContent = look.name.charAt(0).toUpperCase();
|
|
57
|
+
}
|
|
58
|
+
return swatch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function looksView(p: LooksViewOptions): HTMLElement {
|
|
62
|
+
const slots = slotsOf(p.looks);
|
|
63
|
+
const tab = p.tab && slots.includes(p.tab) ? p.tab : slots[0];
|
|
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
|
+
const list = h("ul", { class: "n133s-looks" });
|
|
67
|
+
const row = (pic: HTMLElement, name: string, line: string, action: HTMLElement, worn: boolean) =>
|
|
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
|
+
|
|
70
|
+
if (tab !== OUTFIT && p.takeOff) {
|
|
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 ? 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
|
+
}
|
|
75
|
+
for (const look of p.looks) {
|
|
76
|
+
if (wearOf(look) !== tab) continue;
|
|
77
|
+
const mine = look.priceUsd === 0 || p.owned.has(look.id);
|
|
78
|
+
const worn = p.worn(look);
|
|
79
|
+
const action = 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
|
+
list.append(row(p.picture?.(look) ?? swatchOf(look), look.name, `${what}${state}`, action, worn));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tabs =
|
|
88
|
+
slots.length > 1
|
|
89
|
+
? h(
|
|
90
|
+
"div",
|
|
91
|
+
{ class: "n133s-tabs", role: "tablist", "aria-label": w.kinds },
|
|
92
|
+
...slots.map((s) =>
|
|
93
|
+
h("button", { class: "n133s-tab", type: "button", role: "tab", "aria-selected": s === tab ? "true" : "false", onclick: () => p.onTab?.(s) }, words(s).tab),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
return h(
|
|
99
|
+
"div",
|
|
100
|
+
{ class: "n133s-looks-view" },
|
|
101
|
+
tabs,
|
|
102
|
+
list,
|
|
103
|
+
h("p", { class: "n133s-fine" }, w.looksNote),
|
|
104
|
+
p.signedIn ? null : h("p", { class: "n133s-fine" }, w.signInFirst),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The Looks key and the shop's sheet: `mountShop(element, options)`. Plain DOM, no framework, no Three.js. The sheet holds the looks (wear or buy) and,
|
|
2
|
+
// after Buy, the pay sheet of the order. It talks to the shop's routes ("@nano133/nanoterra/shop", `base`), and to the game only through its hooks:
|
|
3
|
+
//
|
|
4
|
+
// owned() the looks the player owns (the game's GET /api/me gives them) signedIn() whether a player is signed in
|
|
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
|
+
// 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
|
|
8
|
+
//
|
|
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.
|
|
15
|
+
|
|
16
|
+
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
17
|
+
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
18
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
19
|
+
import { checkLooks, wearOf, type Look } from "../../looks/look.js";
|
|
20
|
+
import type { PayView } from "../handler.js";
|
|
21
|
+
import { looksView, slotsOf, type SlotWords } from "./looks.js";
|
|
22
|
+
import { payPanel } from "./pay.js";
|
|
23
|
+
import { shopWords, type ShopWords } from "./words.js";
|
|
24
|
+
|
|
25
|
+
export type ShopMountOptions = {
|
|
26
|
+
/** The game's looks (looks.json as it was read). The server decides every price; these are for the list. */
|
|
27
|
+
looks: unknown;
|
|
28
|
+
/** Where the shop's routes are. Default "/api/orders" (the starter's). */
|
|
29
|
+
base?: string;
|
|
30
|
+
/** The ids of the looks the player owns (free looks need not be listed). Read each time the sheet draws. */
|
|
31
|
+
owned(): readonly string[];
|
|
32
|
+
signedIn(): boolean;
|
|
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. */
|
|
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;
|
|
45
|
+
/** Whether the player wears this look now. */
|
|
46
|
+
worn(look: Look): boolean;
|
|
47
|
+
/** Wear a look the player owns. */
|
|
48
|
+
wear(look: Look): void;
|
|
49
|
+
/** Take off the item of a wear slot (back to the plain tool, or none). */
|
|
50
|
+
takeOff?: (slot: string) => void;
|
|
51
|
+
/** A paid order gave this look: the game adds it to the player's looks (and wears it). */
|
|
52
|
+
onOwned?: (lookId: string, look: Look) => void;
|
|
53
|
+
/** The person check (Turnstile): draws the check in `box` and gives its token, or null when the game has none. */
|
|
54
|
+
personCheck?: (box: HTMLElement) => Promise<string | null>;
|
|
55
|
+
/** The game's own picture of a look, or null for the kit's swatch. */
|
|
56
|
+
picture?: (look: Look) => HTMLElement | null;
|
|
57
|
+
/** Words for the tabs of the wear slots. */
|
|
58
|
+
slots?: Record<string, SlotWords>;
|
|
59
|
+
/** The sheet's title. Default: `words.title` ("Looks"). */
|
|
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>;
|
|
63
|
+
/** Draw the Looks key. Default true; false: the game calls `open()` from its own key. */
|
|
64
|
+
key?: boolean;
|
|
65
|
+
/** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
|
|
66
|
+
theme?: "light" | "dark" | "device";
|
|
67
|
+
onOpen?: () => void;
|
|
68
|
+
onClose?: () => void;
|
|
69
|
+
/** Where the keyboard focus goes when the sheet closes: "game" (the default: out of the sheet, to the page) or "key". */
|
|
70
|
+
focusAfterClose?: "game" | "key";
|
|
71
|
+
/** The `fetch` to use (default: the page's). */
|
|
72
|
+
fetch?: typeof fetch;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type MountedShop = {
|
|
76
|
+
/** Opens the sheet on the looks. */
|
|
77
|
+
open(): void;
|
|
78
|
+
close(): void;
|
|
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
|
+
*/
|
|
83
|
+
buy(lookId: string): void;
|
|
84
|
+
/** Draws the looks again (after the player signed in, or wore something). */
|
|
85
|
+
refresh(): void;
|
|
86
|
+
readonly isOpen: boolean;
|
|
87
|
+
/** Removes the key and the sheet, and stops every timer. */
|
|
88
|
+
destroy(): void;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function mountShop(root: HTMLElement, options: ShopMountOptions): MountedShop {
|
|
92
|
+
const looks = checkLooks(options.looks);
|
|
93
|
+
const api = createApi(options.base ?? "/api/orders", options.fetch);
|
|
94
|
+
const bought = new Set<string>();
|
|
95
|
+
let tab: string | undefined;
|
|
96
|
+
let stopPay: (() => void) | null = null;
|
|
97
|
+
let destroyed = false;
|
|
98
|
+
const cleanup: (() => void)[] = [];
|
|
99
|
+
const words = shopWords(options.words);
|
|
100
|
+
const sheetTitle = options.title ?? words.title;
|
|
101
|
+
|
|
102
|
+
const title = h("h2", { class: "n133s-title", tabindex: "-1" });
|
|
103
|
+
const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
|
|
104
|
+
const body = h("div", { class: "n133s-body" });
|
|
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));
|
|
107
|
+
const wrap = h("div", { class: "n133-shop" }, key, dialog);
|
|
108
|
+
root.append(wrap);
|
|
109
|
+
|
|
110
|
+
// The theme: the game's word, or the page's (data-n133-theme), or the device's setting.
|
|
111
|
+
let media: MediaQueryList | null = null;
|
|
112
|
+
try {
|
|
113
|
+
media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
114
|
+
} catch {
|
|
115
|
+
media = null;
|
|
116
|
+
}
|
|
117
|
+
const drawTheme = () => {
|
|
118
|
+
const want = options.theme ?? "device";
|
|
119
|
+
const page = globalThis.document?.documentElement?.getAttribute("data-n133-theme");
|
|
120
|
+
const dark = want === "device" ? (page === "dark" || page === "light" ? page === "dark" : media?.matches === true) : want === "dark";
|
|
121
|
+
wrap.setAttribute("data-theme", dark ? "dark" : "light");
|
|
122
|
+
};
|
|
123
|
+
drawTheme();
|
|
124
|
+
media?.addEventListener?.("change", drawTheme);
|
|
125
|
+
cleanup.push(() => media?.removeEventListener?.("change", drawTheme));
|
|
126
|
+
|
|
127
|
+
const isOpen = () => dialog.open || dialog.hasAttribute("open");
|
|
128
|
+
const stopTimers = () => {
|
|
129
|
+
stopPay?.();
|
|
130
|
+
stopPay = null;
|
|
131
|
+
};
|
|
132
|
+
const show = (heading: string, content: Node) => {
|
|
133
|
+
stopTimers();
|
|
134
|
+
title.textContent = heading;
|
|
135
|
+
body.replaceChildren(content);
|
|
136
|
+
body.scrollTop = 0;
|
|
137
|
+
if (!isOpen()) {
|
|
138
|
+
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
139
|
+
else dialog.setAttribute("open", "");
|
|
140
|
+
// The first focus is on the title (a screen reader reads it), not on the close key: a ring on Close at every open draws the eye from the looks.
|
|
141
|
+
title.focus();
|
|
142
|
+
key?.setAttribute("aria-expanded", "true");
|
|
143
|
+
options.onOpen?.();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const closed = () => {
|
|
147
|
+
stopTimers();
|
|
148
|
+
key?.setAttribute("aria-expanded", "false");
|
|
149
|
+
options.onClose?.();
|
|
150
|
+
if (options.focusAfterClose === "key" && key) key.focus();
|
|
151
|
+
else releaseFocus(wrap);
|
|
152
|
+
};
|
|
153
|
+
const close = () => {
|
|
154
|
+
if (!isOpen()) return;
|
|
155
|
+
if (typeof dialog.close === "function" && dialog.open) dialog.close();
|
|
156
|
+
else {
|
|
157
|
+
dialog.removeAttribute("open");
|
|
158
|
+
closed();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
dialog.addEventListener("close", closed);
|
|
162
|
+
dialog.addEventListener("click", (e) => {
|
|
163
|
+
if (e.target === dialog) close();
|
|
164
|
+
});
|
|
165
|
+
closeKey.addEventListener("click", close);
|
|
166
|
+
key?.addEventListener("click", () => (isOpen() ? close() : showLooks()));
|
|
167
|
+
|
|
168
|
+
const ownedNow = () => new Set([...options.owned(), ...bought]);
|
|
169
|
+
|
|
170
|
+
function showLooks() {
|
|
171
|
+
if (destroyed) return;
|
|
172
|
+
show(
|
|
173
|
+
sheetTitle,
|
|
174
|
+
looksView({
|
|
175
|
+
looks,
|
|
176
|
+
owned: ownedNow(),
|
|
177
|
+
signedIn: options.signedIn(),
|
|
178
|
+
worn: options.worn,
|
|
179
|
+
wear: (l) => {
|
|
180
|
+
options.wear(l);
|
|
181
|
+
showLooks();
|
|
182
|
+
},
|
|
183
|
+
takeOff: options.takeOff
|
|
184
|
+
? (slot) => {
|
|
185
|
+
options.takeOff!(slot);
|
|
186
|
+
showLooks();
|
|
187
|
+
}
|
|
188
|
+
: undefined,
|
|
189
|
+
buy: (l) => void buy(l, false),
|
|
190
|
+
picture: options.picture,
|
|
191
|
+
slots: options.slots,
|
|
192
|
+
words,
|
|
193
|
+
tab,
|
|
194
|
+
onTab: (s) => {
|
|
195
|
+
tab = s;
|
|
196
|
+
showLooks();
|
|
197
|
+
body.querySelector<HTMLElement>('.n133s-tab[aria-selected="true"]')?.focus();
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const back = (message: string, extra?: HTMLElement | null) =>
|
|
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));
|
|
205
|
+
|
|
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) {
|
|
211
|
+
if (destroyed) return;
|
|
212
|
+
tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
|
|
213
|
+
if (ended || !options.signedIn()) {
|
|
214
|
+
if (!options.signIn) return show(words.buyTitle(look.name), back(words.signInFirst));
|
|
215
|
+
let ok = false;
|
|
216
|
+
try {
|
|
217
|
+
ok = await options.signIn();
|
|
218
|
+
} catch {
|
|
219
|
+
ok = false;
|
|
220
|
+
}
|
|
221
|
+
if (!ok || !options.signedIn()) {
|
|
222
|
+
if (!game) return showLooks();
|
|
223
|
+
close();
|
|
224
|
+
return options.onSignInCancel?.(look.id, look);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const box = h("div", { class: "n133s-turnstile", hidden: true });
|
|
228
|
+
show(words.buyTitle(look.name), h("div", {}, h("p", { class: "n133s-status" }, words.making), box));
|
|
229
|
+
try {
|
|
230
|
+
const turnstile = options.personCheck ? await options.personCheck(box) : null;
|
|
231
|
+
const order = await api.post<PayView>("", { lookId: look.id, turnstile });
|
|
232
|
+
if (destroyed) return;
|
|
233
|
+
const pay = payPanel(order, {
|
|
234
|
+
lookName: look.name,
|
|
235
|
+
check: (id) => api.post<PayView>(`/${id}/check`),
|
|
236
|
+
errorText: (e) => (e instanceof ApiError ? words.refusal(e.message, e.status) : null),
|
|
237
|
+
words,
|
|
238
|
+
onPaid: () => {
|
|
239
|
+
bought.add(look.id);
|
|
240
|
+
options.onOwned?.(look.id, look);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
show(words.buyTitle(look.name), pay.node);
|
|
244
|
+
stopPay = pay.stop;
|
|
245
|
+
} catch (e) {
|
|
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));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
open: showLooks,
|
|
256
|
+
close,
|
|
257
|
+
buy(lookId) {
|
|
258
|
+
const look = looks.find((l) => l.id === lookId);
|
|
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);
|
|
264
|
+
},
|
|
265
|
+
refresh() {
|
|
266
|
+
if (isOpen() && body.querySelector(".n133s-looks-view")) showLooks();
|
|
267
|
+
},
|
|
268
|
+
get isOpen() {
|
|
269
|
+
return isOpen();
|
|
270
|
+
},
|
|
271
|
+
destroy() {
|
|
272
|
+
if (destroyed) return;
|
|
273
|
+
close();
|
|
274
|
+
destroyed = true;
|
|
275
|
+
stopTimers();
|
|
276
|
+
for (const fn of cleanup.splice(0)) fn();
|
|
277
|
+
wrap.remove();
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|