@homie-rocks/studio 0.33.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -1
- package/README.md +6 -6
- package/lib/build.mjs +3 -3
- 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/template-history.json +40 -0
- 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/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
|
});
|