@homie-rocks/studio 0.32.1 → 0.33.1
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 +67 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +22 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/saves-shell.mjs
CHANGED
|
@@ -324,17 +324,23 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
|
|
|
324
324
|
|
|
325
325
|
/* -------------------------------------------------- who is playing, and signing in */
|
|
326
326
|
var sheet = null;
|
|
327
|
+
function account() {
|
|
328
|
+
var href = '/account/?next=' + encodeURIComponent(window.__shell && window.__shell.returnPath ? window.__shell.returnPath() : location.pathname + location.search);
|
|
329
|
+
if (window.__HOMIE_PLAY.embed && window.__shell.openStudio) window.__shell.openStudio(href);
|
|
330
|
+
else location.href = href;
|
|
331
|
+
}
|
|
327
332
|
function paintWho() {
|
|
328
333
|
var row = document.querySelector('[data-who]');
|
|
329
334
|
if (!row) return;
|
|
330
335
|
var name = row.querySelector('[data-who-name]');
|
|
331
336
|
var btn = row.querySelector('[data-who-act]');
|
|
332
|
-
if (S.player && !S.player.guest) { name.textContent = S.player.name; btn.textContent = 'Account'; btn.onclick = function () {
|
|
337
|
+
if (S.player && !S.player.guest) { name.textContent = S.player.name; btn.textContent = 'Account'; btn.onclick = function () { account(); }; }
|
|
333
338
|
else { name.textContent = S.player ? S.player.name + ' (guest)' : 'Guest'; btn.textContent = 'Save my progress'; btn.onclick = function () { openSheet(''); }; }
|
|
334
339
|
row.hidden = false;
|
|
335
340
|
}
|
|
336
341
|
|
|
337
342
|
function openSheet(reason) {
|
|
343
|
+
if (window.__HOMIE_PLAY.embed && window.top !== window) { account(); return; }
|
|
338
344
|
if (sheet) return;
|
|
339
345
|
var signedIn = S.player && !S.player.guest;
|
|
340
346
|
if (signedIn) { tell('player', { player: me(), cache: snapshot(), same: true }); return; }
|
package/worker/schema.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* win, and `aggregateRating`, `review` and `offers` are refused (lib/build.mjs warns). A page of the studio's own
|
|
23
23
|
* (site/pages) takes `<!-- homie:schema -->` where it wants these blocks.
|
|
24
24
|
*/
|
|
25
|
+
import { currencyScale } from './shop-rules.mjs';
|
|
25
26
|
import { licenseOf } from './license.mjs';
|
|
26
27
|
|
|
27
28
|
const CONTEXT = 'https://schema.org';
|
|
@@ -138,14 +139,14 @@ export function shopOffers(shop, g, origin) {
|
|
|
138
139
|
if (!/^[A-Z]{3}$/.test(currency)) return [];
|
|
139
140
|
const now = Date.now();
|
|
140
141
|
return shop.items
|
|
141
|
-
.filter((i) => (!i.game || i.game === g.id) && Number.isInteger(i.price) && i.price
|
|
142
|
+
.filter((i) => (!i.game || i.game === g.id) && Number.isInteger(i.price) && i.price >= 0)
|
|
142
143
|
.filter((i) => !(i.ends && Date.parse(i.ends) < now))
|
|
143
144
|
.map((i) => ({
|
|
144
145
|
'@type': 'Offer',
|
|
145
146
|
name: String(i.name ?? i.id),
|
|
146
|
-
...(text(i.blurb,
|
|
147
|
+
...(text(i.blurb, Infinity) ? { description: text(i.blurb, Infinity) } : {}),
|
|
147
148
|
category: String(i.kind ?? 'item'),
|
|
148
|
-
price: Number((i.price /
|
|
149
|
+
price: Number((i.price / currencyScale(currency)).toFixed(2)),
|
|
149
150
|
priceCurrency: currency,
|
|
150
151
|
url: `${origin}/shop/?game=${encodeURIComponent(g.id)}&item=${encodeURIComponent(i.id)}`,
|
|
151
152
|
availability: 'https://schema.org/OnlineOnly',
|
package/worker/shop-page.mjs
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* button is never the play button. Everything a player or the owner typed is escaped or set as text.
|
|
15
15
|
*/
|
|
16
16
|
import { esc, layout } from './site.mjs';
|
|
17
|
-
import { money } from './shop-rules.mjs';
|
|
17
|
+
import { money, ZERO_DECIMAL, ITEM_ID, currencyScale, amountStep, parseAmount } from './shop-rules.mjs';
|
|
18
18
|
import { PRIVATE, shell } from './office-page.mjs';
|
|
19
19
|
|
|
20
|
+
const refundWords = (shop) => shop.refundDays === null ? 'Ask the studio about refunds.' : `The studio offers self-service refunds for ${shop.policy?.refundUsedItems ? 'items, including used items,' : 'unused items'} within ${shop.refundDays} days from your account page.`;
|
|
21
|
+
|
|
20
22
|
const NOINDEX = { 'x-robots-tag': 'noindex', 'cache-control': 'no-store, private' };
|
|
21
23
|
|
|
22
24
|
export const SHOP_CSS = `
|
|
@@ -45,11 +47,11 @@ export const SHOP_CSS = `
|
|
|
45
47
|
|
|
46
48
|
/** The words for each way an item may be got (shop-rules.mjs wayFor). Plain, never pressing. */
|
|
47
49
|
export const WAY_LINES = Object.freeze({
|
|
48
|
-
'make-an-account': '
|
|
50
|
+
'make-an-account': 'This studio requires an account (a passkey), so purchases stay yours on every device.',
|
|
49
51
|
'age-question': 'One question first.',
|
|
50
52
|
no: 'Not available on this account.',
|
|
51
|
-
'ask-a-parent': '
|
|
52
|
-
cap: 'This account has reached
|
|
53
|
+
'ask-a-parent': 'This studio asks a parent or guardian to buy this on their own phone.',
|
|
54
|
+
cap: 'This account has reached the studio monthly spending limit.',
|
|
53
55
|
'not-yet': 'Not on sale yet.',
|
|
54
56
|
over: 'No longer on sale.',
|
|
55
57
|
closed: 'The shop is not open yet.',
|
|
@@ -59,7 +61,7 @@ export const WAY_LINES = Object.freeze({
|
|
|
59
61
|
export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
|
|
60
62
|
const studio = cat.studio?.name ?? 'Studio';
|
|
61
63
|
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
62
|
-
const boot = { game: g ? g.id : null, item:
|
|
64
|
+
const boot = { game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio, years: yearsList() };
|
|
63
65
|
const main = `<section class="shop" data-shop>
|
|
64
66
|
<p class="kicker">${esc(studio)}</p>
|
|
65
67
|
<h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
|
|
@@ -69,17 +71,17 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
|
|
|
69
71
|
<div data-shop-age hidden></div>
|
|
70
72
|
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : 'The shop is not open yet.'}</p></div>
|
|
71
73
|
<p class="msg" data-shop-msg role="status" aria-live="polite"></p>
|
|
72
|
-
<p class="note">${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing.
|
|
74
|
+
<p class="note">${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.</p>
|
|
73
75
|
</section>
|
|
74
76
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
75
77
|
<script src="/_homie/shop.js" defer></script>`;
|
|
76
78
|
return layout(cat, { title: `Shop · ${studio}`, description: `Things to buy from ${studio}, in real money.`, origin, path: '/shop/', page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, extraHeaders: NOINDEX });
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
/** Years for the age question: this year back
|
|
81
|
+
/** Years for the age question: this year back 120 years, NO default selected, nothing hinting which answer opens anything. */
|
|
80
82
|
function yearsList(now = new Date()) {
|
|
81
83
|
const y = now.getUTCFullYear();
|
|
82
|
-
return Array.from({ length:
|
|
84
|
+
return Array.from({ length: 121 }, (_, i) => y - i);
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
@@ -91,7 +93,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
|
91
93
|
<h1 data-thanks-title>Thank you</h1>
|
|
92
94
|
<p class="lead" data-thanks-line>Stripe is confirming the payment. This page says when it is yours (a few seconds).</p>
|
|
93
95
|
<p>${g ? `<a class="btn" href="/${esc(g.id)}/play">Back to ${esc(g.name)}</a>` : '<a class="btn" href="/">Back to the studio</a>'} <a href="/account/">Your account</a></p>
|
|
94
|
-
<p class="note">Your receipt comes from Stripe by email.
|
|
96
|
+
<p class="note">Your receipt comes from Stripe by email. ${refundWords(shop)}</p>
|
|
95
97
|
</section>
|
|
96
98
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
97
99
|
<script src="/_homie/shop.js" defer></script>`;
|
|
@@ -106,13 +108,13 @@ export function refundsPage(cat, shop) {
|
|
|
106
108
|
<h1>Refunds</h1>
|
|
107
109
|
<p class="lead">${esc(studio)} sells digital items for its games through its own Stripe account. ${esc(studio)} is the seller${shop.till === 'stripe-managed' ? ' (Stripe\'s Link, LLC is the seller of record and handles sales tax; your statement reads LINK.COM*)' : ''}.</p>
|
|
108
110
|
<ul>
|
|
109
|
-
<li
|
|
111
|
+
<li>${refundWords(shop)} <a href="/account/">Your account</a>.</li>
|
|
110
112
|
<li><b>Anything else</b> (an item you used, a problem, a purchase you did not make): ask ${esc(studio)}. The studio's owner can refund any order with one tap.</li>
|
|
111
113
|
<li><b>A parent who paid for a teenager's item</b> can ask the same way.</li>
|
|
112
114
|
<li><b>Disputes:</b> a card dispute never deletes or locks your account. While it is open nothing changes; if the bank decides it for you, that one item is taken back as a refund would.</li>
|
|
113
|
-
<li
|
|
115
|
+
<li>${shop.policy.withdrawalAcknowledgement ? 'Items are delivered to your account at once. Buying one says you want it now, which ends the EU and UK 14-day right to withdraw; the refunds above still apply.' : 'Entitlements are delivered to your account at once. The studio sets its refund terms; your statutory rights still apply.'}</li>
|
|
114
116
|
</ul>
|
|
115
|
-
<p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies.
|
|
117
|
+
<p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies. ${shop.policy?.paidRandomRewards ? '' : 'The studio policy does not offer paid random rewards.'}</p>
|
|
116
118
|
</section>`;
|
|
117
119
|
return layout(cat, { title: `Refunds · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style>`, main });
|
|
118
120
|
}
|
|
@@ -125,12 +127,13 @@ export function parentPage(cat, shop, { gone = false, item = null, name = '', to
|
|
|
125
127
|
else if (paid) body = `<h1>Done</h1><p class="lead">${esc(item.name)} is on ${esc(name)}'s account. Stripe sent your receipt by email.</p>`;
|
|
126
128
|
else {
|
|
127
129
|
body = `<h1>${esc(name)} asked you for ${esc(item.name)}</h1>
|
|
128
|
-
<p class="lead">${esc(item.name)} costs <b>${esc(money(item.price, shop.currency))}</b>${item.days ? ` and lasts ${item.days} days` : ''}, from ${esc(studio)}${item.game ? ` (${esc((cat.games ?? []).find((g) => g.id === item.game)?.name ?? item.game)})` : ''}. ${item.blurb ? esc(item.blurb) : ''}</p>
|
|
130
|
+
<p class="lead">${esc(item.name)} costs <b>${esc(money(item.kind === 'tip' ? item.min : item.price, shop.currency))}</b>${item.days ? ` and lasts ${item.days} days` : ''}, from ${esc(studio)}${item.game ? ` (${esc((cat.games ?? []).find((g) => g.id === item.game)?.name ?? item.game)})` : ''}. ${item.blurb ? esc(item.blurb) : ''}</p>
|
|
129
131
|
<p>It goes to the player account named <b>${esc(name)}</b> on ${esc(studio)}. You pay in your own name on Stripe's page; ${esc(studio)} never sees your card. Saying no is fine: nothing happens.</p>
|
|
130
132
|
${said ? `<p class="msg" role="alert">${esc(said)}</p>` : ''}
|
|
131
133
|
${open ? `<form method="post" style="margin-top:16px"><label style="display:flex;gap:10px;align-items:flex-start;margin:0 0 14px"><input type="checkbox" name="grownup" value="yes" style="width:22px;height:22px;margin:2px 0 0"> <span>I am ${esc(name)}'s parent or guardian, and an adult.</span></label>
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
+
${item.kind === 'tip' ? `<label>Amount in ${esc(shop.currency.toUpperCase())} <input name="amount" type="number" min="${item.min / currencyScale(shop.currency)}" step="${amountStep(shop.currency) / currencyScale(shop.currency)}" value="${item.min / currencyScale(shop.currency)}"${item.max === null ? '' : ` max="${item.max / currencyScale(shop.currency)}"`}></label>` : ''}
|
|
135
|
+
<button class="btn" type="submit">${item.kind === 'tip' ? 'Give' : 'Pay'} ${esc(money(item.kind === 'tip' ? item.min : item.price, shop.currency))} on Stripe</button></form>` : '<p>The shop is not open just now. Try this link again later.</p>'}
|
|
136
|
+
<p class="note">${refundWords(shop)} <a href="/shop/refunds/">refunds</a>.</p>`;
|
|
134
137
|
}
|
|
135
138
|
const main = `<section class="shop"><p class="kicker">${esc(studio)}</p>${body}</section>`;
|
|
136
139
|
return layout(cat, { title: `For ${name || 'a player'} · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, status: gone ? 410 : 200, extraHeaders: { ...NOINDEX, 'referrer-policy': 'no-referrer' } });
|
|
@@ -153,8 +156,10 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
153
156
|
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
154
157
|
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
155
158
|
}
|
|
159
|
+
var units = ${parseAmount.toString()};
|
|
156
160
|
function say(text, bad) { var m = $('[data-shop-msg]'); if (m) { m.textContent = text || ''; m.style.color = bad ? '#ff8a80' : ''; } }
|
|
157
161
|
var q = boot.game ? '?game=' + encodeURIComponent(boot.game) : '';
|
|
162
|
+
if (boot.item) q += (q ? '&' : '?') + 'item=' + encodeURIComponent(boot.item);
|
|
158
163
|
|
|
159
164
|
/* ---------------- /shop/ */
|
|
160
165
|
var list = $('[data-shop-items]');
|
|
@@ -188,17 +193,18 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
188
193
|
if (i.days) card.appendChild(el('p', null, 'Lasts ' + i.days + ' days.'));
|
|
189
194
|
if (i.ends) card.appendChild(el('p', null, 'On sale until ' + new Date(i.ends).toISOString().slice(0, 10) + '.'));
|
|
190
195
|
var act = el('div', { class: 'act' });
|
|
196
|
+
if (i.way === 'cap' && i.retryWay) { act.appendChild(el('span', { class: 'why' }, WAY.cap)); i.way = i.retryWay; }
|
|
191
197
|
if (i.way === 'checkout') {
|
|
192
198
|
var amount = null;
|
|
193
199
|
if (i.kind === 'tip') {
|
|
194
|
-
amount = el('
|
|
195
|
-
|
|
200
|
+
amount = el('input', { type: 'number', 'aria-label': 'How much in ' + s.currency.toUpperCase(), min: String(i.min / s.currencyScale), step: String(s.amountStep / s.currencyScale), value: String(i.min / s.currencyScale) });
|
|
201
|
+
if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
|
|
196
202
|
act.appendChild(amount);
|
|
197
203
|
}
|
|
198
204
|
var b = el('button', { type: 'button', class: 'buy' }, i.kind === 'tip' ? 'Give on Stripe' : 'Buy on Stripe');
|
|
199
205
|
b.addEventListener('click', function () {
|
|
200
206
|
b.disabled = true; say('Opening Stripe…');
|
|
201
|
-
api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ?
|
|
207
|
+
api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? units(amount.value, s.currencyScale) : undefined }).then(function (r) {
|
|
202
208
|
if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
|
|
203
209
|
location.href = r.url;
|
|
204
210
|
}).catch(function (e) { b.disabled = false; say(e.message, true); });
|
|
@@ -227,10 +233,11 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
227
233
|
card.appendChild(act);
|
|
228
234
|
list.appendChild(card);
|
|
229
235
|
});
|
|
236
|
+
if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
|
|
230
237
|
if (!s.items.length) list.appendChild(el('p', null, 'Nothing for sale here right now.'));
|
|
231
238
|
if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
|
|
232
239
|
}
|
|
233
|
-
function load() { api('GET', '/api/shop' + q).then(draw).catch(function () { draw({ ok: false }); }); }
|
|
240
|
+
function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
|
|
234
241
|
if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Nothing was charged.'); load(); }
|
|
235
242
|
|
|
236
243
|
/* ---------------- /shop/thanks */
|
|
@@ -269,7 +276,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
269
276
|
ul.appendChild(li);
|
|
270
277
|
});
|
|
271
278
|
body.appendChild(ul);
|
|
272
|
-
body.appendChild(el('p', { class: 'note' }, '
|
|
279
|
+
body.appendChild(el('p', { class: 'note' }, (r.refundDays === null ? 'Ask the studio about refunds.' : 'The studio offers refunds for ' + (r.refundUsedItems ? 'items' : 'unused items') + ' within ' + r.refundDays + ' days.') + ' Receipts come from Stripe by email.'));
|
|
273
280
|
}).catch(function () {});
|
|
274
281
|
}
|
|
275
282
|
})();
|
|
@@ -284,6 +291,7 @@ export const SHOP_SHELL_CSS = `
|
|
|
284
291
|
.shopsheet h2 { margin: 0 0 4px; font-size: 19px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
|
285
292
|
.shopsheet h2 button { background: none; border: 0; color: #aab3c7; font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; width: 40px; height: 40px; }
|
|
286
293
|
.shopsheet .sub { margin: 0 0 12px; color: #aab3c7; font-size: 13px; }
|
|
294
|
+
.shopsheet a.buy { display: inline-flex; align-items: center; min-height: 44px; color: inherit; font-weight: 700; }
|
|
287
295
|
.shopsheet .it { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: center; padding: 12px 0; border-top: 1px solid rgba(255,255,255,.1); }
|
|
288
296
|
.shopsheet .it b { font-size: 15.5px; }
|
|
289
297
|
.shopsheet .it .pr { font-weight: 800; }
|
|
@@ -302,7 +310,7 @@ export const SHOP_SHELL_CSS = `
|
|
|
302
310
|
/**
|
|
303
311
|
* The play shell's half of `@homie-rocks/studio/shop` (shop/SHOP.md): answers the game's `homie-shop` messages, only
|
|
304
312
|
* from its own frame and only for THIS game, and shows the store sheet when the game (or the room button) asks. On a
|
|
305
|
-
* television
|
|
313
|
+
* television or kids server, visibility and checkout follow the studio policy.
|
|
306
314
|
*/
|
|
307
315
|
export const SHOP_SHELL_JS = String.raw`(function () {
|
|
308
316
|
'use strict';
|
|
@@ -312,8 +320,8 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
312
320
|
if (!frame) return;
|
|
313
321
|
var GAME = boot.game;
|
|
314
322
|
var SERVER = boot.server ? boot.server.id : 'public';
|
|
315
|
-
var kids = !!(boot.server && boot.server.kids);
|
|
316
|
-
var screen = !!boot.screen;
|
|
323
|
+
var kids = !!(boot.server && boot.server.kids && (!cfg || !cfg.policy.kidsServer));
|
|
324
|
+
var screen = !!(boot.screen && (!cfg || !cfg.policy.televisionCheckout));
|
|
317
325
|
var WAY = ${JSON.stringify(WAY_LINES)};
|
|
318
326
|
var owns = [];
|
|
319
327
|
var lastSeen = '';
|
|
@@ -329,7 +337,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
329
337
|
if (!cfg || kids || screen) return Promise.resolve(owns);
|
|
330
338
|
return api('GET', '/api/player/owns' + q).then(function (r) {
|
|
331
339
|
var next = (r && r.owns) || [];
|
|
332
|
-
var key = next.slice().sort()
|
|
340
|
+
var key = JSON.stringify(next.slice().sort());
|
|
333
341
|
if (key !== lastSeen) { var fresh = lastSeen !== '' || owns.length === 0; lastSeen = key; owns = next; post({ ev: 'owns', owns: owns, fresh: fresh }); }
|
|
334
342
|
return owns;
|
|
335
343
|
}).catch(function () { return owns; });
|
|
@@ -355,8 +363,16 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
355
363
|
sheet.addEventListener('click', function (e) { if (e.target === sheet) close(); });
|
|
356
364
|
document.body.appendChild(sheet);
|
|
357
365
|
if (kids || !cfg) { box.appendChild(el('p', { class: 'sub' }, 'Nothing is sold here.')); return; }
|
|
366
|
+
// The player address, and any page shown inside another site's frame, never starts a purchase: it links to the shop.
|
|
367
|
+
var inFrame = false;
|
|
368
|
+
try { inFrame = Boolean(window.top) && window.top !== window; } catch (e) { inFrame = true; }
|
|
369
|
+
if (boot.embed || inFrame) {
|
|
370
|
+
box.appendChild(el('p', { class: 'sub' }, inFrame ? 'Purchases open on the studio’s site, outside this post.' : 'Purchases are made in the studio’s shop.'));
|
|
371
|
+
box.appendChild(el('a', { class: 'buy', href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener noreferrer' }, inFrame ? 'Buy on the studio’s site ↗' : 'Go to the shop'));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
358
374
|
if (screen) {
|
|
359
|
-
// The
|
|
375
|
+
// The studio's protective TV policy: a code to scan and buy on a phone, in the phone's own browser.
|
|
360
376
|
box.appendChild(el('p', { class: 'sub' }, 'Buy on your phone: scan this code. Nothing is sold on this screen.'));
|
|
361
377
|
if (cfg.qr) { var qr = el('div', { class: 'qr' }); qr.innerHTML = cfg.qr; box.appendChild(qr); }
|
|
362
378
|
if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
|
|
@@ -365,14 +381,17 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
365
381
|
box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself. Each purchase opens Stripe\'s own page in a new tab; your game keeps going here.'));
|
|
366
382
|
var said = el('p', { class: 'said', role: 'status' });
|
|
367
383
|
var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
|
|
368
|
-
var foot = el('p', { class: 'foot' }); var all = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener' }, 'The whole shop'); foot.appendChild(all); foot.appendChild(document.createTextNode(' ·
|
|
369
|
-
|
|
384
|
+
var foot = el('p', { class: 'foot' }); var all = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener' }, 'The whole shop'); foot.appendChild(all); foot.appendChild(document.createTextNode(' · see the studio refund terms in the whole shop.')); box.appendChild(foot);
|
|
385
|
+
function loadItems(cursor) {
|
|
386
|
+
listBox.textContent = '';
|
|
387
|
+
api('GET', '/api/shop' + q + '&cursor=' + encodeURIComponent(cursor || '')).then(function (s) {
|
|
370
388
|
if (!s.ok || !s.open) { listBox.appendChild(el('p', { class: 'sub' }, s.kids ? 'Nothing is sold here.' : 'The shop is not open yet.')); return; }
|
|
371
389
|
s.items.forEach(function (i) {
|
|
372
390
|
var row = el('div', { class: 'it' });
|
|
373
391
|
row.appendChild(el('b', null, i.name)); row.appendChild(el('span', { class: 'pr' }, i.shown));
|
|
374
392
|
if (i.blurb) row.appendChild(el('small', null, i.blurb));
|
|
375
393
|
var go = el('div', { class: 'go' });
|
|
394
|
+
if (i.way === 'cap' && i.retryWay) { go.appendChild(el('span', null, WAY.cap)); i.way = i.retryWay; }
|
|
376
395
|
if (i.way === 'checkout' && i.kind !== 'tip') {
|
|
377
396
|
var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
|
|
378
397
|
b.addEventListener('click', function () {
|
|
@@ -397,8 +416,11 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
397
416
|
if (want && want === i.id) setTimeout(function () { row.scrollIntoView && row.scrollIntoView({ block: 'center' }); }, 0);
|
|
398
417
|
listBox.appendChild(row);
|
|
399
418
|
});
|
|
419
|
+
if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { loadItems(s.nextCursor); }; listBox.appendChild(next); }
|
|
400
420
|
if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, 'Nothing for sale here right now.'));
|
|
401
421
|
}).catch(function () { listBox.appendChild(el('p', { class: 'sub' }, 'The shop did not answer.')); });
|
|
422
|
+
}
|
|
423
|
+
loadItems('');
|
|
402
424
|
}
|
|
403
425
|
var opener = document.querySelector('[data-shop-open]');
|
|
404
426
|
if (opener) opener.addEventListener('click', function () { open(null); });
|
|
@@ -419,7 +441,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
419
441
|
if (m.op === 'close') { close(); return reply({ ok: true }); }
|
|
420
442
|
if (m.op === 'used') {
|
|
421
443
|
var key = String(m.key || '');
|
|
422
|
-
if (!/^[
|
|
444
|
+
if (!/^[^\u0000-\u001f\u007f]+$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
|
|
423
445
|
return api('POST', '/api/shop/used', { key: key }).then(function (r) { reply({ ok: !!r.ok }); }).catch(function () { reply({ ok: false }); });
|
|
424
446
|
}
|
|
425
447
|
reply({ ok: false, error: 'op' });
|
|
@@ -439,15 +461,17 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
439
461
|
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
440
462
|
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
441
463
|
}
|
|
442
|
-
|
|
464
|
+
var zeroDecimal = ${JSON.stringify([...ZERO_DECIMAL])};
|
|
465
|
+
function cents(n, c) { var scale = zeroDecimal.indexOf(String(c || 'usd').toLowerCase()) >= 0 ? 1 : ['bhd', 'jod', 'kwd', 'omr', 'tnd'].indexOf(String(c).toLowerCase()) >= 0 ? 1000 : 100; try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: String(c || 'usd').toUpperCase(), minimumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale), maximumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale) }).format(n / scale); } catch (e) { return (n / scale).toFixed(scale === 1 ? 0 : Math.log10(scale)); } }
|
|
443
466
|
function toast(t) { var x = $('#toast'); x.textContent = t; x.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(function () { x.hidden = true; }, 4000); }
|
|
444
467
|
function link(href, text) { var a = el('a', { href: href, target: '_blank', rel: 'noopener' }, text); return a; }
|
|
445
|
-
function load() {
|
|
446
|
-
api('GET', '/_studio/api/shop').then(function (s) {
|
|
468
|
+
function load(cursor, orderCursor) {
|
|
469
|
+
api('GET', '/_studio/api/shop?cursor=' + encodeURIComponent(cursor || '') + '&orderCursor=' + encodeURIComponent(orderCursor || '')).then(function (s) {
|
|
447
470
|
var top = $('#state'); top.textContent = '';
|
|
448
471
|
if (!s.ok) { top.appendChild(el('p', { class: 'empty' }, s.message || 'The shop did not answer.')); return; }
|
|
449
472
|
top.appendChild(el('p', null, s.ready ? ('Open' + (s.mode === 'test' ? ' in TEST MODE (no real money)' : ', live') + (s.shop ? ', till: ' + s.shop.till : '')) : 'Not selling yet.'));
|
|
450
473
|
if (s.missing.length) { var ul = el('ul'); s.missing.forEach(function (m) { ul.appendChild(el('li', null, m.words)); }); top.appendChild(ul); }
|
|
474
|
+
if (s.shop && s.shop.policy) top.appendChild(el('p', { class: 'dim' }, 'Studio policy: ' + s.shop.policy.preset + '. Edit policy and optional amounts in shop.json.'));
|
|
451
475
|
var links = el('p', { class: 'links' });
|
|
452
476
|
[['Payments', s.stripe.payments], ['Refunds', s.stripe.refunds], ['Disputes', s.stripe.disputes], ['Payouts', s.stripe.payouts], ['Balance', s.stripe.balance], ['Tax', s.stripe.tax]].concat(s.stripe.managedPayments ? [['Managed Payments', s.stripe.managedPayments]] : []).forEach(function (l) { links.appendChild(link(l[1], l[0])); });
|
|
453
477
|
links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
|
|
@@ -460,9 +484,18 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
460
484
|
row.appendChild(el('span', null, new Date(o.createdAt).toISOString().slice(0, 16).replace('T', ' ')));
|
|
461
485
|
row.appendChild(el('b', null, o.name + ' · ' + o.shown));
|
|
462
486
|
row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
|
|
463
|
-
row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '')));
|
|
487
|
+
row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '') + (o.note ? ' · ' + o.note : '')));
|
|
464
488
|
if (o.stripe) row.appendChild(link(o.stripe, 'In Stripe'));
|
|
465
489
|
if (o.dispute) row.appendChild(link(o.dispute, 'The dispute'));
|
|
490
|
+
if (o.releasable) {
|
|
491
|
+
var release = el('button', { type: 'button', class: 'ghost small' }, 'Release reservation');
|
|
492
|
+
release.addEventListener('click', function () {
|
|
493
|
+
if (!confirm('Release ' + o.name + ' (' + o.shown + ') from the cap? We ask Stripe to expire an open Checkout Session. This does not refund a payment. A payment that still arrives grants the item, counts toward spending and can be refunded from the office.')) return;
|
|
494
|
+
release.disabled = true;
|
|
495
|
+
api('POST', '/_studio/api/shop/release', { order: o.id }).then(function (r) { if (!r.ok) throw new Error(r.message || 'That did not work.'); toast('Reservation released.'); load(); }).catch(function (e) { release.disabled = false; toast(e.message); });
|
|
496
|
+
});
|
|
497
|
+
row.appendChild(release);
|
|
498
|
+
}
|
|
466
499
|
if (o.refundable) {
|
|
467
500
|
var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
|
|
468
501
|
b.addEventListener('click', function () {
|
|
@@ -474,27 +507,59 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
474
507
|
}
|
|
475
508
|
ord.appendChild(row);
|
|
476
509
|
});
|
|
510
|
+
if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
|
|
477
511
|
var ref = $('#referrals'); ref.textContent = '';
|
|
512
|
+
if (s.referrals.nextCursor) { var next = el('button', { type: 'button' }, 'Next referrers'); next.onclick = function () { load(s.referrals.nextCursor); }; ref.appendChild(next); }
|
|
513
|
+
(s.referrals.failures || []).forEach(function (x) { ref.appendChild(el('p', null, 'Statement failed for ' + x.via + ': ' + (x.why || x.status) + '. Start again after completion to retry.')); });
|
|
478
514
|
var owe = s.referrals.owe || [];
|
|
479
515
|
ref.appendChild(el('h3', null, 'You owe referrers'));
|
|
480
516
|
if (!owe.length) ref.appendChild(el('p', { class: 'dim' }, 'Nothing: no sale came from another site\'s link yet.'));
|
|
481
517
|
owe.forEach(function (b) {
|
|
518
|
+
var detail = el('details'); detail.appendChild(el('summary', null, 'Sales'));
|
|
519
|
+
var sales = el('div'); detail.appendChild(sales);
|
|
520
|
+
var more = el('button', { type: 'button' }, 'Show sales'); detail.appendChild(more);
|
|
521
|
+
var lineCursor = '';
|
|
522
|
+
more.onclick = function () {
|
|
523
|
+
more.disabled = true;
|
|
524
|
+
api('GET', '/_studio/api/shop/lines?via=' + encodeURIComponent(b.via) + '¤cy=' + b.currency + '&cursor=' + encodeURIComponent(lineCursor)).then(function (page) {
|
|
525
|
+
sales.textContent = '';
|
|
526
|
+
page.lines.forEach(function (line) { sales.appendChild(el('p', null, line.date + ' · ' + line.item + ' · ' + cents(line.share, b.currency) + ' · ' + line.state)); });
|
|
527
|
+
lineCursor = page.nextCursor; more.hidden = !lineCursor; more.disabled = false; more.textContent = 'Next sales';
|
|
528
|
+
}).catch(function () { more.disabled = false; toast('Sales did not answer.'); });
|
|
529
|
+
};
|
|
482
530
|
var row = el('div', { class: 'row' });
|
|
483
531
|
row.appendChild(el('b', null, b.via));
|
|
484
|
-
row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.
|
|
532
|
+
row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.lineCount + ' sale(s)'));
|
|
485
533
|
if (b.due > 0) {
|
|
486
534
|
var m = el('button', { type: 'button', class: 'ghost small' }, 'Mark paid');
|
|
487
535
|
m.addEventListener('click', function () {
|
|
488
536
|
var ref0 = prompt('What paid it (an invoice number, a PayPal reference)?'); if (ref0 === null) return;
|
|
489
|
-
api('POST', '/_studio/api/shop/settle', { via: b.via, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
|
|
537
|
+
api('POST', '/_studio/api/shop/settle', { via: b.via, currency: b.currency, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
|
|
490
538
|
});
|
|
491
539
|
row.appendChild(m);
|
|
492
540
|
}
|
|
493
|
-
ref.appendChild(row);
|
|
541
|
+
ref.appendChild(row); ref.appendChild(detail);
|
|
494
542
|
});
|
|
495
543
|
var send = el('button', { type: 'button', class: 'ghost small' }, 'Send last month\'s signed statements');
|
|
496
|
-
send.addEventListener('click',
|
|
497
|
-
|
|
544
|
+
send.addEventListener('click', async function () {
|
|
545
|
+
send.disabled = true; send.textContent = 'Sending; keep this tab open. Close it and press again to resume safely.';
|
|
546
|
+
var cursor = '', period = null, sent = 0, failed = [];
|
|
547
|
+
try {
|
|
548
|
+
do {
|
|
549
|
+
var r = await api('POST', '/_studio/api/shop/statements/send', { cursor: cursor, period: period });
|
|
550
|
+
if (!r.ok) throw new Error(r.message || 'Not sent.');
|
|
551
|
+
sent += r.sent.filter(function (x) { return x.ok; }).length;
|
|
552
|
+
failed = failed.concat(r.sent.filter(function (x) { return !x.ok; }).map(function (x) { return x.via + ': ' + (x.why || x.status); }));
|
|
553
|
+
send.textContent = 'Sent ' + sent + ' pages. Keep open; press again after closing to resume safely.';
|
|
554
|
+
cursor = r.nextCursor; period = r.period;
|
|
555
|
+
if (r.retryAfter) { send.textContent = 'Retrying with backoff; keep open, or press again later to resume safely.'; await new Promise(function (resolve) { setTimeout(resolve, r.retryAfter * 1000); }); }
|
|
556
|
+
} while (cursor !== null);
|
|
557
|
+
if (r.failures) failed = r.failures.map(function (x) { return x.via + ': ' + (x.why || x.status); });
|
|
558
|
+
toast('Sent ' + sent + ' statement pages; ' + failed.length + ' failed.' + (failed.length ? ' ' + failed.join('; ') : ''));
|
|
559
|
+
} catch (e) { toast(e.message); }
|
|
560
|
+
send.disabled = false; send.textContent = 'Send last month’s signed statements';
|
|
561
|
+
});
|
|
562
|
+
ref.appendChild(send);
|
|
498
563
|
ref.appendChild(el('h3', null, 'Owed to you'));
|
|
499
564
|
var inn = s.referrals.owedToUs || [];
|
|
500
565
|
if (!inn.length) ref.appendChild(el('p', { class: 'dim' }, 'No statements yet. When another studio sells to a player you sent, it sends a signed statement here.'));
|
|
@@ -502,6 +567,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
502
567
|
var row = el('div', { class: 'row' });
|
|
503
568
|
row.appendChild(el('b', null, x.seller.replace(/^https:\/\//, '')));
|
|
504
569
|
row.appendChild(el('span', null, x.period + ': due ' + cents(x.due, x.currency) + ', held ' + cents(x.pending, x.currency)));
|
|
570
|
+
row.appendChild(link('/_studio/api/shop/received?seller=' + encodeURIComponent(x.seller) + '&period=' + encodeURIComponent(x.period), 'Signed statement pages'));
|
|
505
571
|
if (x.due > 0) row.appendChild(link(s.referrals.invoice, 'Invoice them in Stripe'));
|
|
506
572
|
ref.appendChild(row);
|
|
507
573
|
});
|