@homie-rocks/studio 0.33.1 → 0.34.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.
@@ -260,7 +260,7 @@ async function dailyCapReached(env) {
260
260
  /** Guests nobody has used for guestIdleDays are forgotten (a few at a time), with everything they kept. */
261
261
  export async function pruneGuests(env) {
262
262
  const now = Date.now();
263
- const ownership = await shopMigrated(env, { complete: false }) ? "AND NOT EXISTS (SELECT 1 FROM entitlements e WHERE e.player = players.id AND e.state = 'active' AND (e.ends_at IS NULL OR e.ends_at > ?2))" : 'AND ?2 > 0';
263
+ const ownership = await shopMigrated(env, { complete: false }) ? "AND NOT EXISTS (SELECT 1 FROM entitlements e WHERE e.player = players.id AND e.state = 'active' AND (e.ends_at IS NULL OR e.ends_at > ?2)) AND NOT EXISTS (SELECT 1 FROM shop_orders o WHERE o.player = players.id AND (o.paid_at IS NOT NULL OR o.session IS NOT NULL))" : 'AND ?2 > 0';
264
264
  const old = (await env.DB.prepare(`SELECT id FROM players WHERE guest = 1 AND seen_at < ?1 ${ownership} LIMIT 25`).bind(now - PLAYER_LIMITS.guestIdleDays * DAY, now).all()).results ?? [];
265
265
  for (const { id } of old) await removePlayer(env, id);
266
266
  await env.DB.prepare('DELETE FROM player_challenges WHERE expires_at < ?1').bind(Date.now()).run();
@@ -1,11 +1,12 @@
1
+ import { referralShare } from './shop-rules.mjs';
1
2
  /**
2
3
  * REFERRALS (@homie-rocks/studio 0.24.0): one set of rules for every referrer, homie.rocks included.
3
4
  *
4
5
  * arrival A person's browser opens one of this studio's pages with ?via=<host> (a link from another studio,
5
- * from homie.rocks, from anywhere). If this browser has never been here (no player of this site), the
6
+ * from homie.rocks, from anywhere). When an optional referral agreement is present, the
6
7
  * Worker keeps the host and the time in a signed cookie (HttpOnly, this site only, for the window).
7
- * Prefetches, crawlers, this site itself and server rooms are never recorded.
8
- * a sale A paid order within the window, by an account made after that first visit, writes a referral line in
8
+ * Prefetches, crawlers and this site itself are not referrals.
9
+ * a sale A paid order within the optional window writes a referral line in
9
10
  * THIS studio's D1 (the seller's books): the pre-tax price, the published rate, the share, held for
10
11
  * `holdDays` (a refund or a lost dispute inside the hold voids it; after it, it nets off the next
11
12
  * statement). The referrer must prove itself: its own /.well-known/homie-studio.json says it takes
@@ -101,23 +102,19 @@ export async function verifyStatement(envelope, x) {
101
102
 
102
103
  /* ------------------------------------------------------------------ arrival */
103
104
 
104
- /** Pages where an arrival may be recorded: the studio's own pages and Play; never a server's page or room. */
105
- const ARRIVAL_PAGE = /^\/(?:|games\/|shop\/|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
106
-
107
105
  /**
108
106
  * A Set-Cookie for this arrival, or null. Only a person's real page load (not a prefetch, a crawler or this site's
109
- * own link), with ?via=<host> of another site, from a browser with no player and no earlier arrival here, on a studio
110
- * that sells and pays referrals, never on a server's page or room.
107
+ * own link), with ?via=<host> of another site, with no earlier attributed arrival here, on a studio
108
+ * that sells and pays referrals.
111
109
  */
112
110
  export async function arrivalCookie(request, env, url, shop) {
113
111
  if (!shop?.open || !shop.referrals || !env?.DB) return null;
114
112
  const via = hostOf(url.searchParams.get('via'));
115
113
  if (!via || via === hostOf(url.hostname)) return null;
116
- if (!ARRIVAL_PAGE.test(url.pathname) || url.searchParams.has('room') || url.searchParams.has('server')) return null;
117
114
  if (!isVisit(request)) return null;
118
115
  const cookies = request.headers.get('cookie') ?? '';
119
116
  // Been here before: a player of this site (a guest who saved, or an account), or an earlier arrival.
120
- if (/(?:^|;\s*)(?:studio_player|studio_via)=/.test(cookies)) return null;
117
+ if (/(?:^|;\s*)studio_via=/.test(cookies) || shop.referralNewPlayersOnly && /(?:^|;\s*)studio_player=/.test(cookies)) return null;
121
118
  const at = Math.floor(Date.now() / 1000);
122
119
  const value = `${via}.${at}.${await macOf(env, `${via}.${at}`)}`;
123
120
  // Persistent integer lifetime; browsers may apply their own cookie retention policy.
@@ -185,7 +182,7 @@ export async function lineFor(env, order, shop, { touchAt = null, now = Date.now
185
182
  const terms = shop?.referrals;
186
183
  if (!terms || !order.via || !(terms.rate > 0)) return [];
187
184
  if (touchAt !== null && terms.windowDays !== null && now - touchAt > Math.round(terms.windowDays * DAY)) return [];
188
- const share = Math.round(Number(order.amount) * terms.rate);
185
+ const share = referralShare(Number(order.amount), terms.rate);
189
186
  if (!Number.isSafeInteger(share)) throw new RangeError('Referral share exceeds safe integer arithmetic; lower the studio rate or amount.');
190
187
  if (share <= 0) return [];
191
188
  const period = new Date(now).toISOString().slice(0, 7);
@@ -308,7 +305,7 @@ export async function settle(env, via, ref, { now = Date.now(), currency } = {})
308
305
  const h = hostOf(via);
309
306
  if (!/^[a-z]{3}$/.test(currency ?? '')) return { ok: false, error: 'currency', message: 'Name the currency to mark paid.' };
310
307
  if (!h) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host' };
311
- const note = String(ref ?? '').replace(/[^\w .:#/-]/g, '').slice(0, 80) || 'paid';
308
+ const note = String(ref ?? '').trim() || 'paid';
312
309
  await env.DB.batch([
313
310
  env.DB.prepare("UPDATE referral_lines SET state = 'settled', settled_ref = ?2 WHERE via = ?1 AND currency = ?4 AND (state = 'owed' OR (state = 'pending' AND hold_until <= ?3))").bind(h, note, now, currency),
314
311
  env.DB.prepare("UPDATE referral_lines SET state = 'settled-clawback', settled_ref = ?2 WHERE via = ?1 AND currency = ?3 AND state = 'clawback'").bind(h, note, currency),
@@ -4,20 +4,19 @@
4
4
  * /shop/ the studio's shop on a phone or a computer: the items in real money, sign in, the one
5
5
  * neutral age question, Buy (Stripe's own page), or "ask a grown-up" for 13-17
6
6
  * /shop/thanks back from Stripe: "it's yours" once the webhook says paid
7
- * /shop/refunds/ the refund policy every till must publish
7
+ * /shop/refunds/ the studio refund settings
8
8
  * /shop/parent/<token> the page a parent opens on their own device: what, for whom, the price, pay on Stripe
9
9
  * /_homie/shop.js the one script of /shop/ and of the account page's purchases card
10
10
  * the play shell's store sheet (SHOP_SHELL_*): a game's `shop.open()`; on a television only a code to scan
11
11
  * the office's shop page (/_studio/office/shop)
12
12
  *
13
- * No countdown, no "only N left", no pressure words, nothing aimed at children; every price in real money; the buy
14
- * button is never the play button. Everything a player or the owner typed is escaped or set as text.
13
+ * Studio-authored text is escaped or set as text. Checkout follows the studio's chosen settings.
15
14
  */
16
15
  import { esc, layout } from './site.mjs';
17
16
  import { money, ZERO_DECIMAL, ITEM_ID, currencyScale, amountStep, parseAmount } from './shop-rules.mjs';
18
17
  import { PRIVATE, shell } from './office-page.mjs';
19
18
 
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.`;
19
+ 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. Tips are refunded only by the studio.`;
21
20
 
22
21
  const NOINDEX = { 'x-robots-tag': 'noindex', 'cache-control': 'no-store, private' };
23
22
 
@@ -61,12 +60,12 @@ export const WAY_LINES = Object.freeze({
61
60
  export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
62
61
  const studio = cat.studio?.name ?? 'Studio';
63
62
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
64
- const boot = { game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio, years: yearsList() };
63
+ const boot = { game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
65
64
  const main = `<section class="shop" data-shop>
66
65
  <p class="kicker">${esc(studio)}</p>
67
66
  <h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
68
67
  ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test cards only)</p>' : ''}
69
- <p class="lead">Things you can buy here, in real money, from ${esc(studio)} itself. Every purchase goes through Stripe's own checkout page, one at a time. ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
68
+ <p class="lead">Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access. ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
70
69
  <noscript><p class="lead">This page needs JavaScript.</p></noscript>
71
70
  <div data-shop-age hidden></div>
72
71
  <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : 'The shop is not open yet.'}</p></div>
@@ -78,12 +77,6 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
78
77
  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 });
79
78
  }
80
79
 
81
- /** Years for the age question: this year back 120 years, NO default selected, nothing hinting which answer opens anything. */
82
- function yearsList(now = new Date()) {
83
- const y = now.getUTCFullYear();
84
- return Array.from({ length: 121 }, (_, i) => y - i);
85
- }
86
-
87
80
  export function thanksPage(cat, shop, { session = null, game = null } = {}) {
88
81
  const studio = cat.studio?.name ?? 'Studio';
89
82
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
@@ -114,25 +107,27 @@ export function refundsPage(cat, shop) {
114
107
  <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>
115
108
  <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>
116
109
  </ul>
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>
110
+ <p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies.</p>
118
111
  </section>`;
119
112
  return layout(cat, { title: `Refunds · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style>`, main });
120
113
  }
121
114
 
122
115
  /** The page a parent opens: no sign-in, no script; one checkbox and one button to Stripe's page. */
123
- export function parentPage(cat, shop, { gone = false, item = null, name = '', token = null, open = true, paid = false, said = '' } = {}) {
116
+ export function parentPage(cat, shop, { gone = false, item = null, lines = null, name = '', token = null, open = true, paid = false, said = '' } = {}) {
124
117
  const studio = cat.studio?.name ?? 'Studio';
118
+ lines ??= item ? [{ item, quantity: 1 }] : [];
125
119
  let body;
126
120
  if (gone || !item) body = '<h1>This link has ended</h1><p class="lead">Links like this last a week and work for one purchase. Ask for a new one if you still want to buy it.</p>';
127
121
  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>`;
128
122
  else {
129
123
  body = `<h1>${esc(name)} asked you for ${esc(item.name)}</h1>
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>
124
+ <p class="lead">${esc(item.name)} costs <b>${esc(money(item.price === 'choose' ? 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>
131
125
  <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>
132
126
  ${said ? `<p class="msg" role="alert">${esc(said)}</p>` : ''}
133
127
  ${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>
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>'}
128
+ ${lines.map((line, index) => `<p>${esc(line.item.name)} × ${line.quantity}: ${esc(money((line.item.price === 'choose' ? line.item.min : line.item.price) * line.quantity, shop.currency))}</p>${line.item.price === 'choose' ? `<label>Amount per item in ${esc(shop.currency.toUpperCase())} <input name="${lines.length === 1 ? 'amount' : `amount_${index}`}" type="number" min="${line.item.min / currencyScale(shop.currency)}" step="${amountStep(shop.currency) / currencyScale(shop.currency)}" value="${line.item.min / currencyScale(shop.currency)}"${line.item.max === null ? '' : ` max="${line.item.max / currencyScale(shop.currency)}"`}></label>` : ''}`).join('')}
129
+
130
+ <button class="btn" type="submit">Pay cart on Stripe</button></form>` : '<p>The shop is not open just now. Try this link again later.</p>'}
136
131
  <p class="note">${refundWords(shop)} <a href="/shop/refunds/">refunds</a>.</p>`;
137
132
  }
138
133
  const main = `<section class="shop"><p class="kicker">${esc(studio)}</p>${body}</section>`;
@@ -152,6 +147,13 @@ export const SHOP_JS = String.raw`(function () {
152
147
  function $(s, r) { return (r || document).querySelector(s); }
153
148
  function el(tag, attrs, text) { var n = document.createElement(tag); if (attrs) Object.keys(attrs).forEach(function (k) { n.setAttribute(k, attrs[k]); }); if (text !== undefined) n.textContent = text; return n; }
154
149
  function api(method, path, body) {
150
+ if (path === '/api/shop/buy') {
151
+ // A guest's purchase is tied to this browser. Check persistence before taking money.
152
+ document.cookie = 'shop_cookie_check=1; Path=/; SameSite=Lax';
153
+ var cookiesWork = /(?:^|; )shop_cookie_check=1(?:;|$)/.test(document.cookie);
154
+ document.cookie = 'shop_cookie_check=; Path=/; Max-Age=0; SameSite=Lax';
155
+ if (!cookiesWork) return Promise.resolve({ ok: false, error: 'cookies', message: 'Allow cookies for this site before buying so you can keep access to your purchases.' });
156
+ }
155
157
  var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
156
158
  if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
157
159
  return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
@@ -163,6 +165,21 @@ export const SHOP_JS = String.raw`(function () {
163
165
 
164
166
  /* ---------------- /shop/ */
165
167
  var list = $('[data-shop-items]');
168
+ var cart = [];
169
+ var cartBox = list ? el('div') : null;
170
+ if (cartBox) list.after(cartBox);
171
+ function drawCart() {
172
+ cartBox.textContent = '';
173
+ cart.forEach(function (line, index) {
174
+ var row = el('p', null, line.name + ' × ' + line.quantity + ' ');
175
+ var remove = el('button', { type: 'button' }, 'Remove'); remove.onclick = function () { cart.splice(index, 1); drawCart(); };
176
+ row.appendChild(remove); cartBox.appendChild(row);
177
+ });
178
+ if (!cart.length) return;
179
+ var checkout = el('button', { type: 'button', class: 'btn' }, 'Checkout cart');
180
+ checkout.onclick = function () { checkout.disabled = true; api('POST', '/api/shop/buy', { lines: cart, game: boot.game }).then(function (r) { if (!r.ok) throw new Error(r.message); location.href = r.url; }).catch(function (e) { checkout.disabled = false; say(e.message, true); }); };
181
+ cartBox.appendChild(checkout);
182
+ }
166
183
  function draw(s) {
167
184
  list.textContent = '';
168
185
  if (!s.ok) { list.appendChild(el('p', null, s.message || 'The shop did not answer.')); return; }
@@ -174,9 +191,7 @@ export const SHOP_JS = String.raw`(function () {
174
191
  if (needsAge) {
175
192
  age.className = 'age';
176
193
  age.appendChild(el('p', null, 'What year were you born? (Asked once, for this account.)'));
177
- var sel = el('select', { 'aria-label': 'Year you were born' });
178
- var blank = el('option', { value: '' }, 'Year'); blank.selected = true; sel.appendChild(blank);
179
- (boot.years || []).forEach(function (y) { sel.appendChild(el('option', { value: String(y) }, String(y))); });
194
+ var sel = el('input', { type: 'number', min: '0', step: '1', 'aria-label': 'Year you were born' });
180
195
  var ok = el('button', { type: 'button', class: 'btn' }, 'Done');
181
196
  ok.addEventListener('click', function () {
182
197
  if (!sel.value) { say('Pick a year.', true); return; }
@@ -196,12 +211,12 @@ export const SHOP_JS = String.raw`(function () {
196
211
  if (i.way === 'cap' && i.retryWay) { act.appendChild(el('span', { class: 'why' }, WAY.cap)); i.way = i.retryWay; }
197
212
  if (i.way === 'checkout') {
198
213
  var amount = null;
199
- if (i.kind === 'tip') {
214
+ if (i.price === 'choose') {
200
215
  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
216
  if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
202
217
  act.appendChild(amount);
203
218
  }
204
- var b = el('button', { type: 'button', class: 'buy' }, i.kind === 'tip' ? 'Give on Stripe' : 'Buy on Stripe');
219
+ var b = el('button', { type: 'button', class: 'buy' }, i.price === 'choose' ? 'Give on Stripe' : 'Buy on Stripe');
205
220
  b.addEventListener('click', function () {
206
221
  b.disabled = true; say('Opening Stripe…');
207
222
  api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? units(amount.value, s.currencyScale) : undefined }).then(function (r) {
@@ -210,6 +225,10 @@ export const SHOP_JS = String.raw`(function () {
210
225
  }).catch(function (e) { b.disabled = false; say(e.message, true); });
211
226
  });
212
227
  act.appendChild(b);
228
+ var quantity = el('input', { type: 'number', min: '1', step: '1', value: '1', 'aria-label': 'Quantity' });
229
+ var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
230
+ add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
231
+ act.appendChild(quantity); act.appendChild(add);
213
232
  } else if (i.way === 'owned') {
214
233
  act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
215
234
  } else if (i.way === 'make-an-account') {
@@ -238,15 +257,24 @@ export const SHOP_JS = String.raw`(function () {
238
257
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
239
258
  }
240
259
  function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
241
- if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Nothing was charged.'); load(); }
260
+ if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Checkout cancelled. Any open reservation is released once Stripe confirms it.'); load(); }
242
261
 
262
+ function returnToPage(cancelled) {
263
+ try {
264
+ var saved = JSON.parse(sessionStorage.getItem('shop-return') || 'null');
265
+ if (!saved || typeof saved.path !== 'string' || saved.path[0] !== '/' || new URL(saved.path, location.origin).origin !== location.origin) return;
266
+ if (cancelled && new URLSearchParams(location.search).get('order') !== saved.order) return;
267
+ sessionStorage.removeItem('shop-return'); location.href = saved.path;
268
+ } catch (e) {}
269
+ }
270
+ if (/[?&]cancelled=1/.test(location.search)) returnToPage(true);
243
271
  /* ---------------- /shop/thanks */
244
272
  if (boot.thanks) {
245
273
  var tries = 0;
246
274
  (function poll() {
247
275
  tries += 1;
248
276
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
249
- if (r.status === 'paid') { $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
277
+ if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
250
278
  if (tries < 40) setTimeout(poll, 1500);
251
279
  else $('[data-thanks-line]').textContent = 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
252
280
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
@@ -262,14 +290,14 @@ export const SHOP_JS = String.raw`(function () {
262
290
  var body = $('[data-shop-body]', card);
263
291
  if (r.badges && r.badges.length) { var bl = el('p'); r.badges.forEach(function (b) { bl.appendChild(el('span', { class: 'badge' }, b)); }); body.appendChild(bl); }
264
292
  var ul = el('ul');
265
- r.orders.forEach(function (o) {
293
+ r.orders.flatMap(function (order) { return order.lines.map(function (line) { return Object.assign({}, order, line, { order: order.id }); }); }).forEach(function (o) {
266
294
  var li = el('li', null, o.name + ' · ' + o.shown + ' · ' + new Date(o.createdAt).toISOString().slice(0, 10) + ' · ' + o.status + ' ');
267
295
  if (o.refundable) {
268
296
  var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
269
297
  b.addEventListener('click', function () {
270
298
  if (!confirm('Refund ' + o.name + ' (' + o.shown + ')? It leaves your account and the money goes back to your card in 5 to 10 days.')) return;
271
299
  b.disabled = true;
272
- api('POST', '/api/shop/refund', { order: o.id }).then(function (x) { if (!x.ok) throw new Error(x.message); li.textContent = o.name + ' · refunded'; }).catch(function (e) { b.disabled = false; alert(e.message); });
300
+ api('POST', '/api/shop/refund', { order: o.order, line: o.id }).then(function (x) { if (!x.ok) throw new Error(x.message); li.textContent = o.name + ' · refunded'; }).catch(function (e) { b.disabled = false; alert(e.message); });
273
301
  });
274
302
  li.appendChild(b);
275
303
  }
@@ -326,6 +354,13 @@ export const SHOP_SHELL_JS = String.raw`(function () {
326
354
  var owns = [];
327
355
  var lastSeen = '';
328
356
  function api(method, path, body) {
357
+ if (path === '/api/shop/buy') {
358
+ // A guest's purchase is tied to this browser. Check persistence before taking money.
359
+ document.cookie = 'shop_cookie_check=1; Path=/; SameSite=Lax';
360
+ var cookiesWork = /(?:^|; )shop_cookie_check=1(?:;|$)/.test(document.cookie);
361
+ document.cookie = 'shop_cookie_check=; Path=/; Max-Age=0; SameSite=Lax';
362
+ if (!cookiesWork) return Promise.resolve({ ok: false, error: 'cookies', message: 'Allow cookies for this site before buying so you can keep access to your purchases.' });
363
+ }
329
364
  var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
330
365
  if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
331
366
  return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
@@ -342,6 +377,11 @@ export const SHOP_SHELL_JS = String.raw`(function () {
342
377
  return owns;
343
378
  }).catch(function () { return owns; });
344
379
  }
380
+ function leaveForCheckout(tab, result) {
381
+ if (tab) { tab.location.href = result.url; return; }
382
+ try { sessionStorage.setItem('shop-return', JSON.stringify({ order: result.order, path: location.pathname + location.search + location.hash })); } catch (e) {}
383
+ location.href = result.url;
384
+ }
345
385
  var watching = 0;
346
386
  /** After a checkout opens in a new tab: look again when this tab comes back, and every few seconds for two minutes. */
347
387
  function watch() {
@@ -378,7 +418,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
378
418
  if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
379
419
  return;
380
420
  }
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.'));
421
+ box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself. Stripe opens in a new tab when available. Otherwise checkout leaves this page and returns here after payment.'));
382
422
  var said = el('p', { class: 'said', role: 'status' });
383
423
  var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
384
424
  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);
@@ -392,7 +432,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
392
432
  if (i.blurb) row.appendChild(el('small', null, i.blurb));
393
433
  var go = el('div', { class: 'go' });
394
434
  if (i.way === 'cap' && i.retryWay) { go.appendChild(el('span', null, WAY.cap)); i.way = i.retryWay; }
395
- if (i.way === 'checkout' && i.kind !== 'tip') {
435
+ if (i.way === 'checkout' && i.price !== 'choose') {
396
436
  var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
397
437
  b.addEventListener('click', function () {
398
438
  // The new tab opens on the tap (a browser allows that), then goes to Stripe's page once it is made.
@@ -400,7 +440,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
400
440
  b.disabled = true; said.textContent = 'Opening Stripe…';
401
441
  api('POST', '/api/shop/buy', { item: i.id, game: GAME, server: SERVER }).then(function (r) {
402
442
  if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
403
- if (tab) tab.location.href = r.url; else location.assign(r.url);
443
+ leaveForCheckout(tab, r);
404
444
  said.textContent = 'Finish on Stripe\'s page. It shows up here once it is paid.';
405
445
  watch();
406
446
  }).catch(function (e) { if (tab) tab.close(); b.disabled = false; said.textContent = e.message; });
@@ -409,7 +449,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
409
449
  } else if (i.way === 'owned') go.appendChild(el('span', null, 'Yours ✓'));
410
450
  else {
411
451
  var a = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME) + '&item=' + encodeURIComponent(i.id), target: '_blank', rel: 'noopener' }, i.way === 'make-an-account' ? 'Make an account' : i.way === 'age-question' ? 'One question first' : i.way === 'ask-a-parent' ? 'Ask a parent' : 'More');
412
- if (i.way === 'make-an-account' || i.way === 'age-question' || i.way === 'ask-a-parent' || i.kind === 'tip') { go.appendChild(a); a.addEventListener('click', watch); }
452
+ if (i.way === 'make-an-account' || i.way === 'age-question' || i.way === 'ask-a-parent' || i.price === 'choose') { go.appendChild(a); a.addEventListener('click', watch); }
413
453
  go.appendChild(el('span', null, WAY[i.way] || ''));
414
454
  }
415
455
  row.appendChild(go);
@@ -439,6 +479,14 @@ export const SHOP_SHELL_JS = String.raw`(function () {
439
479
  if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
440
480
  if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
441
481
  if (m.op === 'close') { close(); return reply({ ok: true }); }
482
+ if (m.op === 'checkout') {
483
+ if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
484
+ var checkoutTab = window.open('', '_blank');
485
+ return api('POST', '/api/shop/buy', { lines: m.lines, game: GAME, server: SERVER }).then(function (r) {
486
+ if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
487
+ reply(r);
488
+ }).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
489
+ }
442
490
  if (m.op === 'used') {
443
491
  var key = String(m.key || '');
444
492
  if (!/^[^\u0000-\u001f\u007f]+$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
@@ -505,6 +553,18 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
505
553
  });
506
554
  row.appendChild(b);
507
555
  }
556
+ (o.lines || []).forEach(function (line) {
557
+ var detail = el('span', null, line.name + ' × ' + line.quantity + ' · ' + cents(line.amount, o.currency) + ' · ' + line.status);
558
+ if (o.refundable && line.status !== 'refunded') {
559
+ var refundLine = el('button', { type: 'button' }, 'Refund line');
560
+ refundLine.onclick = function () {
561
+ refundLine.disabled = true;
562
+ api('POST', '/_studio/api/shop/refund', { order: o.id, line: line.id }).then(function (r) { if (!r.ok) throw new Error(r.message); load(); }).catch(function (e) { refundLine.disabled = false; toast(e.message); });
563
+ };
564
+ detail.appendChild(refundLine);
565
+ }
566
+ row.appendChild(detail);
567
+ });
508
568
  ord.appendChild(row);
509
569
  });
510
570
  if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
@@ -591,7 +651,7 @@ export async function officeShopPage(cat) {
591
651
  <div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
592
652
  <div class="game" style="padding:6px 16px 12px" id="referrals"></div>
593
653
  <div class="toast" id="toast" role="status" hidden></div>
594
- <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refund is one tap here (your AI can only ask, and you confirm). A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
654
+ <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refund is one tap here (your AI can only ask, and you confirm). A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
595
655
  return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
596
656
  headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
597
657
  });