@homie-rocks/studio 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -1
- package/README.md +6 -6
- package/lib/build.mjs +4 -4
- package/lib/cloudflare.mjs +3 -2
- package/lib/scaffold.mjs +37 -38
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop.mjs +13 -14
- package/lib/studio.mjs +5 -4
- package/lib/template-history.json +80 -0
- package/netplay/NETPLAY.md +1 -1
- package/package.json +1 -1
- package/shop/SELLING.md +14 -92
- package/shop/SHOP.md +147 -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-page.mjs +91 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +552 -212
- package/worker/stripe.mjs +21 -12
- 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),
|
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') {
|
|
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,10 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
210
225
|
}).catch(function (e) { b.disabled = false; say(e.message, true); });
|
|
211
226
|
});
|
|
212
227
|
act.appendChild(b);
|
|
228
|
+
var quantity = el('input', { type: 'number', min: '1', step: '1', value: '1', 'aria-label': 'Quantity' });
|
|
229
|
+
var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
|
|
230
|
+
add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
|
|
231
|
+
act.appendChild(quantity); act.appendChild(add);
|
|
213
232
|
} else if (i.way === 'owned') {
|
|
214
233
|
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
215
234
|
} else if (i.way === 'make-an-account') {
|
|
@@ -238,15 +257,24 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
238
257
|
if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
|
|
239
258
|
}
|
|
240
259
|
function load(cursor) { api('GET', '/api/shop' + q + (q ? '&' : '?') + 'cursor=' + encodeURIComponent(cursor || '')).then(draw).catch(function () { draw({ ok: false }); }); }
|
|
241
|
-
if (list) { if (/[?&]cancelled=1/.test(location.search)) say('
|
|
260
|
+
if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Checkout cancelled. Any open reservation is released once Stripe confirms it.'); load(); }
|
|
242
261
|
|
|
262
|
+
function returnToPage(cancelled) {
|
|
263
|
+
try {
|
|
264
|
+
var saved = JSON.parse(sessionStorage.getItem('shop-return') || 'null');
|
|
265
|
+
if (!saved || typeof saved.path !== 'string' || saved.path[0] !== '/' || new URL(saved.path, location.origin).origin !== location.origin) return;
|
|
266
|
+
if (cancelled && new URLSearchParams(location.search).get('order') !== saved.order) return;
|
|
267
|
+
sessionStorage.removeItem('shop-return'); location.href = saved.path;
|
|
268
|
+
} catch (e) {}
|
|
269
|
+
}
|
|
270
|
+
if (/[?&]cancelled=1/.test(location.search)) returnToPage(true);
|
|
243
271
|
/* ---------------- /shop/thanks */
|
|
244
272
|
if (boot.thanks) {
|
|
245
273
|
var tries = 0;
|
|
246
274
|
(function poll() {
|
|
247
275
|
tries += 1;
|
|
248
276
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
249
|
-
if (r.status === 'paid') { $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
|
|
277
|
+
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = 'It\'s yours'; $('[data-thanks-line]').textContent = 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
|
|
250
278
|
if (tries < 40) setTimeout(poll, 1500);
|
|
251
279
|
else $('[data-thanks-line]').textContent = 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
|
252
280
|
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
@@ -262,14 +290,14 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
262
290
|
var body = $('[data-shop-body]', card);
|
|
263
291
|
if (r.badges && r.badges.length) { var bl = el('p'); r.badges.forEach(function (b) { bl.appendChild(el('span', { class: 'badge' }, b)); }); body.appendChild(bl); }
|
|
264
292
|
var ul = el('ul');
|
|
265
|
-
r.orders.forEach(function (o) {
|
|
293
|
+
r.orders.flatMap(function (order) { return order.lines.map(function (line) { return Object.assign({}, order, line, { order: order.id }); }); }).forEach(function (o) {
|
|
266
294
|
var li = el('li', null, o.name + ' · ' + o.shown + ' · ' + new Date(o.createdAt).toISOString().slice(0, 10) + ' · ' + o.status + ' ');
|
|
267
295
|
if (o.refundable) {
|
|
268
296
|
var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
|
|
269
297
|
b.addEventListener('click', function () {
|
|
270
298
|
if (!confirm('Refund ' + o.name + ' (' + o.shown + ')? It leaves your account and the money goes back to your card in 5 to 10 days.')) return;
|
|
271
299
|
b.disabled = true;
|
|
272
|
-
api('POST', '/api/shop/refund', { order: o.id }).then(function (x) { if (!x.ok) throw new Error(x.message); li.textContent = o.name + ' · refunded'; }).catch(function (e) { b.disabled = false; alert(e.message); });
|
|
300
|
+
api('POST', '/api/shop/refund', { order: o.order, line: o.id }).then(function (x) { if (!x.ok) throw new Error(x.message); li.textContent = o.name + ' · refunded'; }).catch(function (e) { b.disabled = false; alert(e.message); });
|
|
273
301
|
});
|
|
274
302
|
li.appendChild(b);
|
|
275
303
|
}
|
|
@@ -326,6 +354,13 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
326
354
|
var owns = [];
|
|
327
355
|
var lastSeen = '';
|
|
328
356
|
function api(method, path, body) {
|
|
357
|
+
if (path === '/api/shop/buy') {
|
|
358
|
+
// A guest's purchase is tied to this browser. Check persistence before taking money.
|
|
359
|
+
document.cookie = 'shop_cookie_check=1; Path=/; SameSite=Lax';
|
|
360
|
+
var cookiesWork = /(?:^|; )shop_cookie_check=1(?:;|$)/.test(document.cookie);
|
|
361
|
+
document.cookie = 'shop_cookie_check=; Path=/; Max-Age=0; SameSite=Lax';
|
|
362
|
+
if (!cookiesWork) return Promise.resolve({ ok: false, error: 'cookies', message: 'Allow cookies for this site before buying so you can keep access to your purchases.' });
|
|
363
|
+
}
|
|
329
364
|
var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
|
|
330
365
|
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
331
366
|
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
@@ -342,6 +377,11 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
342
377
|
return owns;
|
|
343
378
|
}).catch(function () { return owns; });
|
|
344
379
|
}
|
|
380
|
+
function leaveForCheckout(tab, result) {
|
|
381
|
+
if (tab) { tab.location.href = result.url; return; }
|
|
382
|
+
try { sessionStorage.setItem('shop-return', JSON.stringify({ order: result.order, path: location.pathname + location.search + location.hash })); } catch (e) {}
|
|
383
|
+
location.href = result.url;
|
|
384
|
+
}
|
|
345
385
|
var watching = 0;
|
|
346
386
|
/** After a checkout opens in a new tab: look again when this tab comes back, and every few seconds for two minutes. */
|
|
347
387
|
function watch() {
|
|
@@ -378,7 +418,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
378
418
|
if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
|
|
379
419
|
return;
|
|
380
420
|
}
|
|
381
|
-
box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself.
|
|
421
|
+
box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself. Stripe opens in a new tab when available. Otherwise checkout leaves this page and returns here after payment.'));
|
|
382
422
|
var said = el('p', { class: 'said', role: 'status' });
|
|
383
423
|
var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
|
|
384
424
|
var foot = el('p', { class: 'foot' }); var all = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME), target: '_blank', rel: 'noopener' }, 'The whole shop'); foot.appendChild(all); foot.appendChild(document.createTextNode(' · see the studio refund terms in the whole shop.')); box.appendChild(foot);
|
|
@@ -392,7 +432,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
392
432
|
if (i.blurb) row.appendChild(el('small', null, i.blurb));
|
|
393
433
|
var go = el('div', { class: 'go' });
|
|
394
434
|
if (i.way === 'cap' && i.retryWay) { go.appendChild(el('span', null, WAY.cap)); i.way = i.retryWay; }
|
|
395
|
-
if (i.way === 'checkout' && i.
|
|
435
|
+
if (i.way === 'checkout' && i.price !== 'choose') {
|
|
396
436
|
var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
|
|
397
437
|
b.addEventListener('click', function () {
|
|
398
438
|
// The new tab opens on the tap (a browser allows that), then goes to Stripe's page once it is made.
|
|
@@ -400,7 +440,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
400
440
|
b.disabled = true; said.textContent = 'Opening Stripe…';
|
|
401
441
|
api('POST', '/api/shop/buy', { item: i.id, game: GAME, server: SERVER }).then(function (r) {
|
|
402
442
|
if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
|
|
403
|
-
|
|
443
|
+
leaveForCheckout(tab, r);
|
|
404
444
|
said.textContent = 'Finish on Stripe\'s page. It shows up here once it is paid.';
|
|
405
445
|
watch();
|
|
406
446
|
}).catch(function (e) { if (tab) tab.close(); b.disabled = false; said.textContent = e.message; });
|
|
@@ -409,7 +449,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
409
449
|
} else if (i.way === 'owned') go.appendChild(el('span', null, 'Yours ✓'));
|
|
410
450
|
else {
|
|
411
451
|
var a = el('a', { href: '/shop/?game=' + encodeURIComponent(GAME) + '&item=' + encodeURIComponent(i.id), target: '_blank', rel: 'noopener' }, i.way === 'make-an-account' ? 'Make an account' : i.way === 'age-question' ? 'One question first' : i.way === 'ask-a-parent' ? 'Ask a parent' : 'More');
|
|
412
|
-
if (i.way === 'make-an-account' || i.way === 'age-question' || i.way === 'ask-a-parent' || i.
|
|
452
|
+
if (i.way === 'make-an-account' || i.way === 'age-question' || i.way === 'ask-a-parent' || i.price === 'choose') { go.appendChild(a); a.addEventListener('click', watch); }
|
|
413
453
|
go.appendChild(el('span', null, WAY[i.way] || ''));
|
|
414
454
|
}
|
|
415
455
|
row.appendChild(go);
|
|
@@ -439,6 +479,14 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
439
479
|
if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
|
|
440
480
|
if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
|
|
441
481
|
if (m.op === 'close') { close(); return reply({ ok: true }); }
|
|
482
|
+
if (m.op === 'checkout') {
|
|
483
|
+
if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
|
|
484
|
+
var checkoutTab = window.open('', '_blank');
|
|
485
|
+
return api('POST', '/api/shop/buy', { lines: m.lines, game: GAME, server: SERVER }).then(function (r) {
|
|
486
|
+
if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
|
|
487
|
+
reply(r);
|
|
488
|
+
}).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
|
|
489
|
+
}
|
|
442
490
|
if (m.op === 'used') {
|
|
443
491
|
var key = String(m.key || '');
|
|
444
492
|
if (!/^[^\u0000-\u001f\u007f]+$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
|
|
@@ -505,6 +553,18 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
505
553
|
});
|
|
506
554
|
row.appendChild(b);
|
|
507
555
|
}
|
|
556
|
+
(o.lines || []).forEach(function (line) {
|
|
557
|
+
var detail = el('span', null, line.name + ' × ' + line.quantity + ' · ' + cents(line.amount, o.currency) + ' · ' + line.status);
|
|
558
|
+
if (o.refundable && line.status !== 'refunded') {
|
|
559
|
+
var refundLine = el('button', { type: 'button' }, 'Refund line');
|
|
560
|
+
refundLine.onclick = function () {
|
|
561
|
+
refundLine.disabled = true;
|
|
562
|
+
api('POST', '/_studio/api/shop/refund', { order: o.id, line: line.id }).then(function (r) { if (!r.ok) throw new Error(r.message); load(); }).catch(function (e) { refundLine.disabled = false; toast(e.message); });
|
|
563
|
+
};
|
|
564
|
+
detail.appendChild(refundLine);
|
|
565
|
+
}
|
|
566
|
+
row.appendChild(detail);
|
|
567
|
+
});
|
|
508
568
|
ord.appendChild(row);
|
|
509
569
|
});
|
|
510
570
|
if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
|
|
@@ -591,7 +651,7 @@ export async function officeShopPage(cat) {
|
|
|
591
651
|
<div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
|
|
592
652
|
<div class="game" style="padding:6px 16px 12px" id="referrals"></div>
|
|
593
653
|
<div class="toast" id="toast" role="status" hidden></div>
|
|
594
|
-
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refund is one tap here (your AI can only ask, and you confirm). A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
|
|
654
|
+
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refund is one tap here (your AI can only ask, and you confirm). A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
|
|
595
655
|
return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
|
|
596
656
|
headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
|
|
597
657
|
});
|