@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/CHANGELOG.md +53 -1
- package/README.md +18 -14
- package/bin/homie-studio.mjs +4 -4
- package/lib/build.mjs +3 -3
- package/lib/cloudflare.mjs +3 -2
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +45 -39
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop-links.mjs +91 -0
- package/lib/shop.mjs +28 -20
- package/lib/stripe-connect.mjs +135 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/shop/SELLING.md +15 -91
- package/shop/SHOP.md +185 -274
- package/shop/shop.ts +20 -5
- package/worker/index.mjs +7 -1
- package/worker/office.mjs +1 -1
- package/worker/players.mjs +1 -1
- package/worker/referrals.mjs +9 -12
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +93 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +638 -223
- package/worker/stripe.mjs +22 -13
- package/worker/version.mjs +1 -1
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
|
|
11
|
-
* shop.used('skin:ember'); // equipped:
|
|
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
|
-
/**
|
|
31
|
+
/** The studio policy hides this shop on the current kids server. */
|
|
32
32
|
kids: boolean;
|
|
33
|
-
/**
|
|
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.
|
|
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' };
|
package/worker/players.mjs
CHANGED
|
@@ -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();
|
package/worker/referrals.mjs
CHANGED
|
@@ -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).
|
|
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
|
|
8
|
-
* a sale A paid order within the window
|
|
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,
|
|
110
|
-
* that sells and pays referrals
|
|
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*)(
|
|
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 =
|
|
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 ?? '').
|
|
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
|
+
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
135
|
-
|
|
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('
|
|
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.
|
|
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.
|
|
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('
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
});
|