@homie-rocks/studio 0.33.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shop/shop.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  * await shop.ready;
8
8
  * if (shop.has('skin:ember')) hull.material = ember; // what the player bought, by the key shop.json gives
9
9
  * shop.on('change', () => redrawSkins()); // a purchase landed (or a refund took one back)
10
- * button.onclick = () => shop.open('ember-skin'); // the store sheet; on a TV, a code to buy on a phone
11
- * shop.used('skin:ember'); // equipped: it is no longer refundable by the player
10
+ * button.onclick = () => shop.open('ember-skin'); // the store sheet
11
+ * shop.used('skin:ember'); // equipped: the studio refund policy applies
12
12
  *
13
13
  * WHERE IT COMES FROM. The game runs in a sandboxed frame that can see no cookie and send no request as the site. The
14
14
  * play shell around it (on the studio's own origin) asks the studio's Worker what the signed-in player owns and
@@ -28,9 +28,9 @@ export const SHOP_VERSION = 1;
28
28
  export interface ShopState {
29
29
  /** The studio sells something in this game, and this room may show it. */
30
30
  open: boolean;
31
- /** A kids server: nothing is sold or shown. */
31
+ /** The studio policy hides this shop on the current kids server. */
32
32
  kids: boolean;
33
- /** This is the big screen: `open()` shows a code to buy on a phone. */
33
+ /** The studio policy uses a phone code on this screen. */
34
34
  screen: boolean;
35
35
  }
36
36
  export type ShopEvent = 'change' | 'closed';
@@ -42,6 +42,8 @@ export interface ShopOptions {
42
42
  timeoutMs?: number;
43
43
  }
44
44
 
45
+ export interface CartLine { item: string; quantity: number; amount?: number }
46
+
45
47
  export interface Shop {
46
48
  /** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
47
49
  readonly ready: Promise<ShopState>;
@@ -52,9 +54,15 @@ export interface Shop {
52
54
  entitlements(): string[];
53
55
  /** Ask the shell for a fresh answer (the shell also looks again by itself after a purchase). */
54
56
  refresh(): Promise<string[]>;
55
- /** Show the store sheet, at one item if given. Call it from a button the player pressed, never on a timer. */
57
+ /** Show the store sheet, at one item if given. The studio chooses where to open it. */
56
58
  open(item?: string): void;
57
59
  close(): void;
60
+ /** Add an item and quantity to the local cart. Tip amounts use Stripe currency units. */
61
+ add(item: string, quantity?: number, amount?: number): void;
62
+ cart(): CartLine[];
63
+ clear(): void;
64
+ checkout(): Promise<boolean>;
65
+ buy(item: string, quantity?: number, amount?: number): Promise<boolean>;
58
66
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
59
67
  used(key: string): Promise<boolean>;
60
68
  on(ev: 'change', fn: (owns: string[]) => void): () => void;
@@ -92,6 +100,7 @@ export function createShop(opts: ShopOptions = {}): Shop {
92
100
  if (target && g.addEventListener) {
93
101
  g.addEventListener('message', (e: MessageEvent) => {
94
102
  const m = e.data as Msg;
103
+ if (e.source !== target) return;
95
104
  if (!m || typeof m !== 'object' || m.t !== 'homie-shop') return;
96
105
  if (typeof m.q === 'number' && waiting.has(m.q)) { const r = waiting.get(m.q)!; waiting.delete(m.q); r(m); return; }
97
106
  if (m.ev === 'owns') { if (setOwns(m.owns)) emit('change', owns.slice()); }
@@ -109,6 +118,7 @@ export function createShop(opts: ShopOptions = {}): Shop {
109
118
  ])
110
119
  : Promise.resolve({ ...state });
111
120
 
121
+ let cart: CartLine[] = [];
112
122
  const shop: Shop = {
113
123
  ready,
114
124
  get state() { return { ...state }; },
@@ -116,6 +126,11 @@ export function createShop(opts: ShopOptions = {}): Shop {
116
126
  entitlements: () => owns.slice(),
117
127
  refresh: async () => { const m = await send({ op: 'owns' }); if (m.ok && setOwns(m.owns)) emit('change', owns.slice()); return owns.slice(); },
118
128
  open: (item) => { void send({ op: 'open', ...(typeof item === 'string' ? { item } : {}) }); },
129
+ add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
130
+ cart: () => cart.map((line) => ({ ...line })),
131
+ clear: () => { cart = []; },
132
+ checkout: async () => { const ok = Boolean((await send({ op: 'checkout', lines: cart })).ok); if (ok) cart = []; return ok; },
133
+ buy: async (item, quantity = 1, amount) => Boolean((await send({ op: 'checkout', lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
119
134
  close: () => { void send({ op: 'close' }); },
120
135
  used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
121
136
  on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
package/worker/index.mjs CHANGED
@@ -118,7 +118,7 @@ import {
118
118
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
119
119
  usePlayers(playerAccounts);
120
120
  import { STUDIO_VERSION_TAG } from './version.mjs';
121
- import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
121
+ import { reconcileOrders, readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
122
122
  import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
123
123
  import { ldScript, studioNode } from './schema.mjs';
124
124
  import { arrivalCookie, manifestReferrals } from './referrals.mjs';
@@ -591,6 +591,12 @@ function finish(res, path) {
591
591
  }
592
592
 
593
593
  export default {
594
+ async scheduled(event, env, ctx) {
595
+ ctx.waitUntil((async () => {
596
+ const cat = await catalogue(env, 'https://studio.invalid');
597
+ await reconcileOrders(env, shopOf(cat));
598
+ })());
599
+ },
594
600
  async fetch(request, env, ctx) {
595
601
  const url = new URL(request.url);
596
602
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
package/worker/office.mjs CHANGED
@@ -1109,7 +1109,7 @@ export async function perform(env, cat, a) {
1109
1109
  if (a.mode === 'off') notes.push('The guides are the game\'s plain bots.');
1110
1110
  return { ok: true, op: a.op, game: meta.id, server: sv.id, brain: sv.brain, budget: day.budget, note: `Saved. ${notes.join(' ')}`.trim() };
1111
1111
  }
1112
- case 'shop-release': return performRelease(env, a);
1112
+ case 'shop-release': return performRelease(env, a, cat);
1113
1113
  case 'refund': return performRefund(env, cat, a);
1114
1114
  case 'shop-settle': return settleReferrer(env, a);
1115
1115
  default: return { ok: false, error: 'op' };
@@ -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),
@@ -0,0 +1,46 @@
1
+ /** Shared setup/runtime contract. Payment Links are public; credentials never enter this value. */
2
+ import { KEY_SHAPE, modeOf } from './stripe.mjs';
3
+ // Current documented Payment Link fields, isolated from the existing keyed transport version.
4
+ export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
5
+ export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
6
+ export function linkConfig(env) {
7
+ try { const c = JSON.parse(env?.STRIPE_SHOP_LINKS ?? 'null'); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
8
+ }
9
+ export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
10
+ export function linkSettings(shop) {
11
+ return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
12
+ cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
13
+ }
14
+ export function fullerReason(shop) {
15
+ if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
16
+ if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
17
+ return null;
18
+ }
19
+ export function paymentLinkURL(value) {
20
+ try { const u = new URL(value); return u.protocol === 'https:' && u.hostname === 'buy.stripe.com' && !u.username && !u.password && !u.port; } catch { return false; }
21
+ }
22
+
23
+ /** Only signed snapshot events call this. Store financial fields, never customer/card data.
24
+ * Existing meta is a durable KV table. One row per provider object avoids read/modify/write races.
25
+ * Terminal failed/canceled refunds beat stale succeeded snapshots (including same-second events).
26
+ */
27
+ export async function rememberMoneyEvent(env, event) {
28
+ const o = event.data.object, mode = event.livemode ? 'live' : 'test';
29
+ const put = async (kind, value, rank) => {
30
+ if (!/^pi_\w+$/.test(value.payment_intent ?? '') || !/^(re|dp)_\w+$/.test(value.id ?? '')) return;
31
+ const key = `shop-money:${mode}:${value.payment_intent}:${kind}:${value.id}`;
32
+ const record = JSON.stringify({ ...value, at: event.created ?? 0, rank });
33
+ await env.DB.prepare(`INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value
34
+ WHERE json_extract(excluded.value, '$.rank') > json_extract(meta.value, '$.rank') OR
35
+ (json_extract(excluded.value, '$.rank') = json_extract(meta.value, '$.rank') AND json_extract(excluded.value, '$.at') >= json_extract(meta.value, '$.at'))`).bind(key, record).run();
36
+ };
37
+ const refund = (r) => put('refund', { id: r.id, payment_intent: r.payment_intent ?? o.payment_intent, amount: r.amount, currency: r.currency ?? o.currency, status: r.status, metadata: r.metadata?.line ? { line: r.metadata.line } : {} }, ['failed', 'canceled'].includes(r.status) ? 3 : r.status === 'succeeded' ? 2 : 1);
38
+ if (event.type.startsWith('refund.')) await refund(o);
39
+ if (event.type === 'charge.refunded') for (const r of o.refunds?.data ?? []) await refund(r);
40
+ if (event.type.startsWith('charge.dispute.')) await put('dispute', { id: o.id, payment_intent: o.payment_intent, status: o.status }, event.type.endsWith('.closed') ? 2 : 1);
41
+ }
42
+ export async function rememberedMoney(env, payment, mode) {
43
+ const prefix = `shop-money:${mode}:${payment}:`;
44
+ const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
45
+ return rows.map((r) => JSON.parse(r.value));
46
+ }
@@ -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' && !s.keyless) {
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,11 @@ 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
+ if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
232
+ else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
213
233
  } else if (i.way === 'owned') {
214
234
  act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
215
235
  } else if (i.way === 'make-an-account') {
@@ -238,15 +258,24 @@ export const SHOP_JS = String.raw`(function () {
238
258
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
239
259
  }
240
260
  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(); }
261
+ if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Checkout cancelled. Any open reservation is released once Stripe confirms it.'); load(); }
242
262
 
263
+ function returnToPage(cancelled) {
264
+ try {
265
+ var saved = JSON.parse(sessionStorage.getItem('shop-return') || 'null');
266
+ if (!saved || typeof saved.path !== 'string' || saved.path[0] !== '/' || new URL(saved.path, location.origin).origin !== location.origin) return;
267
+ if (cancelled && new URLSearchParams(location.search).get('order') !== saved.order) return;
268
+ sessionStorage.removeItem('shop-return'); location.href = saved.path;
269
+ } catch (e) {}
270
+ }
271
+ if (/[?&]cancelled=1/.test(location.search)) returnToPage(true);
243
272
  /* ---------------- /shop/thanks */
244
273
  if (boot.thanks) {
245
274
  var tries = 0;
246
275
  (function poll() {
247
276
  tries += 1;
248
277
  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; }
278
+ 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
279
  if (tries < 40) setTimeout(poll, 1500);
251
280
  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
281
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
@@ -262,14 +291,14 @@ export const SHOP_JS = String.raw`(function () {
262
291
  var body = $('[data-shop-body]', card);
263
292
  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
293
  var ul = el('ul');
265
- r.orders.forEach(function (o) {
294
+ r.orders.flatMap(function (order) { return order.lines.map(function (line) { return Object.assign({}, order, line, { order: order.id }); }); }).forEach(function (o) {
266
295
  var li = el('li', null, o.name + ' · ' + o.shown + ' · ' + new Date(o.createdAt).toISOString().slice(0, 10) + ' · ' + o.status + ' ');
267
296
  if (o.refundable) {
268
297
  var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
269
298
  b.addEventListener('click', function () {
270
299
  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
300
  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); });
301
+ 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
302
  });
274
303
  li.appendChild(b);
275
304
  }
@@ -326,6 +355,13 @@ export const SHOP_SHELL_JS = String.raw`(function () {
326
355
  var owns = [];
327
356
  var lastSeen = '';
328
357
  function api(method, path, body) {
358
+ if (path === '/api/shop/buy') {
359
+ // A guest's purchase is tied to this browser. Check persistence before taking money.
360
+ document.cookie = 'shop_cookie_check=1; Path=/; SameSite=Lax';
361
+ var cookiesWork = /(?:^|; )shop_cookie_check=1(?:;|$)/.test(document.cookie);
362
+ document.cookie = 'shop_cookie_check=; Path=/; Max-Age=0; SameSite=Lax';
363
+ 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.' });
364
+ }
329
365
  var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
330
366
  if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
331
367
  return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
@@ -342,6 +378,11 @@ export const SHOP_SHELL_JS = String.raw`(function () {
342
378
  return owns;
343
379
  }).catch(function () { return owns; });
344
380
  }
381
+ function leaveForCheckout(tab, result) {
382
+ if (tab) { tab.location.href = result.url; return; }
383
+ try { sessionStorage.setItem('shop-return', JSON.stringify({ order: result.order, path: location.pathname + location.search + location.hash })); } catch (e) {}
384
+ location.href = result.url;
385
+ }
345
386
  var watching = 0;
346
387
  /** After a checkout opens in a new tab: look again when this tab comes back, and every few seconds for two minutes. */
347
388
  function watch() {
@@ -378,7 +419,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
378
419
  if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
379
420
  return;
380
421
  }
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.'));
422
+ 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
423
  var said = el('p', { class: 'said', role: 'status' });
383
424
  var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
384
425
  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 +433,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
392
433
  if (i.blurb) row.appendChild(el('small', null, i.blurb));
393
434
  var go = el('div', { class: 'go' });
394
435
  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') {
436
+ if (i.way === 'checkout' && i.price !== 'choose') {
396
437
  var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
397
438
  b.addEventListener('click', function () {
398
439
  // The new tab opens on the tap (a browser allows that), then goes to Stripe's page once it is made.
@@ -400,7 +441,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
400
441
  b.disabled = true; said.textContent = 'Opening Stripe…';
401
442
  api('POST', '/api/shop/buy', { item: i.id, game: GAME, server: SERVER }).then(function (r) {
402
443
  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);
444
+ leaveForCheckout(tab, r);
404
445
  said.textContent = 'Finish on Stripe\'s page. It shows up here once it is paid.';
405
446
  watch();
406
447
  }).catch(function (e) { if (tab) tab.close(); b.disabled = false; said.textContent = e.message; });
@@ -409,7 +450,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
409
450
  } else if (i.way === 'owned') go.appendChild(el('span', null, 'Yours ✓'));
410
451
  else {
411
452
  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); }
453
+ 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
454
  go.appendChild(el('span', null, WAY[i.way] || ''));
414
455
  }
415
456
  row.appendChild(go);
@@ -439,6 +480,14 @@ export const SHOP_SHELL_JS = String.raw`(function () {
439
480
  if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
440
481
  if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
441
482
  if (m.op === 'close') { close(); return reply({ ok: true }); }
483
+ if (m.op === 'checkout') {
484
+ if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
485
+ var checkoutTab = window.open('', '_blank');
486
+ return api('POST', '/api/shop/buy', { lines: m.lines, game: GAME, server: SERVER }).then(function (r) {
487
+ if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
488
+ reply(r);
489
+ }).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
490
+ }
442
491
  if (m.op === 'used') {
443
492
  var key = String(m.key || '');
444
493
  if (!/^[^\u0000-\u001f\u007f]+$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
@@ -476,6 +525,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
476
525
  [['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])); });
477
526
  links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
478
527
  top.appendChild(links);
528
+ if (s.keyless) top.appendChild(el('p', { class: 'note' }, 'Refund in Stripe or ask your AI connected to Stripe. If a payment is missing here, ask your AI to resend its Stripe webhook.'));
479
529
  if (s.totals) top.appendChild(el('p', { class: 'dim' }, 'Last 30 days: ' + s.totals.sales + ' sales, ' + cents(s.totals.paid, s.totals.currency) + ' before tax and fees; ' + s.totals.refunds + ' refunded (' + cents(s.totals.refunded, s.totals.currency) + '); ' + s.totals.disputes + ' disputed now, ' + s.totals.lost + ' lost.'));
480
530
  var ord = $('#orders'); ord.textContent = '';
481
531
  if (!s.orders.length) ord.appendChild(el('p', { class: 'empty' }, 'No orders yet.'));
@@ -505,6 +555,18 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
505
555
  });
506
556
  row.appendChild(b);
507
557
  }
558
+ (o.lines || []).forEach(function (line) {
559
+ var detail = el('span', null, line.name + ' × ' + line.quantity + ' · ' + cents(line.amount, o.currency) + ' · ' + line.status);
560
+ if (o.refundable && line.status !== 'refunded') {
561
+ var refundLine = el('button', { type: 'button' }, 'Refund line');
562
+ refundLine.onclick = function () {
563
+ refundLine.disabled = true;
564
+ 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); });
565
+ };
566
+ detail.appendChild(refundLine);
567
+ }
568
+ row.appendChild(detail);
569
+ });
508
570
  ord.appendChild(row);
509
571
  });
510
572
  if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
@@ -591,7 +653,7 @@ export async function officeShopPage(cat) {
591
653
  <div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
592
654
  <div class="game" style="padding:6px 16px 12px" id="referrals"></div>
593
655
  <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>`;
656
+ <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. 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
657
  return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
596
658
  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
659
  });