@homie-rocks/studio 0.23.0 → 0.24.2
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 +56 -1
- package/README.md +22 -0
- package/bin/homie-studio.mjs +34 -1
- package/lib/build.mjs +13 -2
- package/lib/scaffold.mjs +48 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +6 -0
- package/package.json +6 -1
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +11 -0
- package/worker/account-page.mjs +14 -2
- package/worker/index.mjs +43 -4
- package/worker/office-page.mjs +5 -5
- package/worker/office.mjs +36 -0
- package/worker/pages.mjs +7 -5
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +3 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shop's pages (@homie-rocks/studio 0.24.0; worker/shop.mjs has the rules):
|
|
3
|
+
*
|
|
4
|
+
* /shop/ the studio's shop on a phone or a computer: the items in real money, sign in, the one
|
|
5
|
+
* neutral age question, Buy (Stripe's own page), or "ask a grown-up" for 13-17
|
|
6
|
+
* /shop/thanks back from Stripe: "it's yours" once the webhook says paid
|
|
7
|
+
* /shop/refunds/ the refund policy every till must publish
|
|
8
|
+
* /shop/parent/<token> the page a parent opens on their own device: what, for whom, the price, pay on Stripe
|
|
9
|
+
* /_homie/shop.js the one script of /shop/ and of the account page's purchases card
|
|
10
|
+
* the play shell's store sheet (SHOP_SHELL_*): a game's `shop.open()`; on a television only a code to scan
|
|
11
|
+
* the office's shop page (/_studio/office/shop)
|
|
12
|
+
*
|
|
13
|
+
* No countdown, no "only N left", no pressure words, nothing aimed at children; every price in real money; the buy
|
|
14
|
+
* button is never the play button. Everything a player or the owner typed is escaped or set as text.
|
|
15
|
+
*/
|
|
16
|
+
import { esc, layout } from './site.mjs';
|
|
17
|
+
import { money } from './shop-rules.mjs';
|
|
18
|
+
import { PRIVATE, shell } from './office-page.mjs';
|
|
19
|
+
|
|
20
|
+
const NOINDEX = { 'x-robots-tag': 'noindex', 'cache-control': 'no-store, private' };
|
|
21
|
+
|
|
22
|
+
export const SHOP_CSS = `
|
|
23
|
+
.shop{max-width:720px;margin:0 auto;padding:28px 16px 64px}
|
|
24
|
+
.shop .lead{max-width:60ch}
|
|
25
|
+
.shop .items{display:grid;gap:12px;margin:20px 0}
|
|
26
|
+
.shop .item{display:grid;grid-template-columns:1fr auto;gap:6px 14px;align-items:center;padding:16px;border-radius:16px;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.06)}
|
|
27
|
+
.shop .item h2{margin:0;font-size:18px}
|
|
28
|
+
.shop .item p{margin:0;grid-column:1/-1;opacity:.82;font-size:14.5px}
|
|
29
|
+
.shop .item .price{font:800 18px/1 var(--display,ui-sans-serif)}
|
|
30
|
+
.shop .item .act{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
31
|
+
.shop .item .act button,.shop .item .act a{min-height:44px;padding:0 18px;border-radius:12px;font:700 15px/1 ui-sans-serif,system-ui,sans-serif;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center}
|
|
32
|
+
.shop .item .act .buy{background:var(--hot,#ffcf5a);color:var(--hot-ink,#0b0b10);border:0}
|
|
33
|
+
.shop .item .act .plain{background:transparent;color:inherit;border:1px solid rgba(127,127,127,.4)}
|
|
34
|
+
.shop .item .owned{font-weight:700;opacity:.9}
|
|
35
|
+
.shop .item .why{opacity:.75;font-size:14px}
|
|
36
|
+
.shop .age{padding:16px;border-radius:16px;border:1px solid rgba(127,127,127,.28);margin:16px 0}
|
|
37
|
+
.shop .age select{min-height:44px;font:16px ui-sans-serif,system-ui,sans-serif;padding:0 10px;border-radius:10px;margin-right:8px}
|
|
38
|
+
.shop .note{font-size:13.5px;opacity:.75;max-width:62ch}
|
|
39
|
+
.shop .test{display:inline-block;padding:4px 10px;border-radius:999px;border:1px dashed currentColor;font:700 12px/1.2 ui-monospace,Menlo,monospace;margin-bottom:10px}
|
|
40
|
+
.shop .msg{min-height:1.4em;font-weight:600}
|
|
41
|
+
.shop .link{word-break:break-all;font:13px/1.4 ui-monospace,Menlo,monospace;padding:10px;border-radius:10px;background:rgba(127,127,127,.12);margin:8px 0}
|
|
42
|
+
.shop-card ul{margin:8px 0 0;padding-left:18px}
|
|
43
|
+
.shop-card .badge,.shop .badge{display:inline-block;padding:3px 10px;border-radius:999px;background:var(--hot,#ffcf5a);color:var(--hot-ink,#0b0b10);font:800 12px/1.3 ui-sans-serif,system-ui,sans-serif;margin-right:6px}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
/** The words for each way an item may be got (shop-rules.mjs wayFor). Plain, never pressing. */
|
|
47
|
+
export const WAY_LINES = Object.freeze({
|
|
48
|
+
'make-an-account': 'Make an account (a passkey) first, so what you buy stays yours on every device.',
|
|
49
|
+
'age-question': 'One question first.',
|
|
50
|
+
no: 'Not available on this account.',
|
|
51
|
+
'ask-a-parent': 'A parent or guardian can buy this for you on their own phone.',
|
|
52
|
+
cap: 'This account has reached this month\'s limit here.',
|
|
53
|
+
'not-yet': 'Not on sale yet.',
|
|
54
|
+
over: 'No longer on sale.',
|
|
55
|
+
closed: 'The shop is not open yet.',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** /shop/: the page; the list is drawn by /_homie/shop.js from /api/shop. */
|
|
59
|
+
export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
|
|
60
|
+
const studio = cat.studio?.name ?? 'Studio';
|
|
61
|
+
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
62
|
+
const boot = { game: g ? g.id : null, item: /^[a-z0-9][a-z0-9-]{0,39}$/.test(String(item ?? '')) ? item : null, studio, years: yearsList() };
|
|
63
|
+
const main = `<section class="shop" data-shop>
|
|
64
|
+
<p class="kicker">${esc(studio)}</p>
|
|
65
|
+
<h1>${g ? `${esc(g.name)}: shop` : 'Shop'}</h1>
|
|
66
|
+
${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test cards only)</p>' : ''}
|
|
67
|
+
<p class="lead">Things you can buy here, in real money, from ${esc(studio)} itself. Every purchase goes through Stripe's own checkout page, one at a time. ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
|
|
68
|
+
<noscript><p class="lead">This page needs JavaScript.</p></noscript>
|
|
69
|
+
<div data-shop-age hidden></div>
|
|
70
|
+
<div class="items" data-shop-items><p>${open ? 'Reading the shop…' : 'The shop is not open yet.'}</p></div>
|
|
71
|
+
<p class="msg" data-shop-msg role="status" aria-live="polite"></p>
|
|
72
|
+
<p class="note">${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. An unused item can be refunded within ${shop.refundDays} days from your account page; <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.</p>
|
|
73
|
+
</section>
|
|
74
|
+
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
75
|
+
<script src="/_homie/shop.js" defer></script>`;
|
|
76
|
+
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 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Years for the age question: this year back 100 years, NO default selected, nothing hinting which answer opens anything. */
|
|
80
|
+
function yearsList(now = new Date()) {
|
|
81
|
+
const y = now.getUTCFullYear();
|
|
82
|
+
return Array.from({ length: 101 }, (_, i) => y - i);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
86
|
+
const studio = cat.studio?.name ?? 'Studio';
|
|
87
|
+
const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
|
|
88
|
+
const boot = { thanks: session, game: g ? g.id : null, studio };
|
|
89
|
+
const main = `<section class="shop" data-shop-thanks>
|
|
90
|
+
<p class="kicker">${esc(studio)}</p>
|
|
91
|
+
<h1 data-thanks-title>Thank you</h1>
|
|
92
|
+
<p class="lead" data-thanks-line>Stripe is confirming the payment. This page says when it is yours (a few seconds).</p>
|
|
93
|
+
<p>${g ? `<a class="btn" href="/${esc(g.id)}/play">Back to ${esc(g.name)}</a>` : '<a class="btn" href="/">Back to the studio</a>'} <a href="/account/">Your account</a></p>
|
|
94
|
+
<p class="note">Your receipt comes from Stripe by email. Changed your mind about an unused item? Refund it from your account page within ${shop.refundDays} days.</p>
|
|
95
|
+
</section>
|
|
96
|
+
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
97
|
+
<script src="/_homie/shop.js" defer></script>`;
|
|
98
|
+
return layout(cat, { title: `Thank you · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, extraHeaders: NOINDEX });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The refund policy (Stripe asks every seller for one; the kit's is generous by default). */
|
|
102
|
+
export function refundsPage(cat, shop) {
|
|
103
|
+
const studio = cat.studio?.name ?? 'Studio';
|
|
104
|
+
const main = `<section class="shop">
|
|
105
|
+
<p class="kicker">${esc(studio)}</p>
|
|
106
|
+
<h1>Refunds</h1>
|
|
107
|
+
<p class="lead">${esc(studio)} sells digital items for its games through its own Stripe account. ${esc(studio)} is the seller${shop.till === 'stripe-managed' ? ' (Stripe\'s Link, LLC is the seller of record and handles sales tax; your statement reads LINK.COM*)' : ''}.</p>
|
|
108
|
+
<ul>
|
|
109
|
+
<li><b>Unused items, within ${shop.refundDays} days:</b> refund them yourself from your <a href="/account/">account page</a>. The money goes back to the card you paid with in 5 to 10 days, and the item leaves your account.</li>
|
|
110
|
+
<li><b>Anything else</b> (an item you used, a problem, a purchase you did not make): ask ${esc(studio)}. The studio's owner can refund any order with one tap.</li>
|
|
111
|
+
<li><b>A parent who paid for a teenager's item</b> can ask the same way.</li>
|
|
112
|
+
<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>
|
|
113
|
+
<li>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.</li>
|
|
114
|
+
</ul>
|
|
115
|
+
<p class="note">Prices are in ${esc(shop.currency.toUpperCase())}. Tax is added at checkout where it applies. Nothing here is random: you always see exactly what you buy.</p>
|
|
116
|
+
</section>`;
|
|
117
|
+
return layout(cat, { title: `Refunds · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style>`, main });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The page a parent opens: no sign-in, no script; one checkbox and one button to Stripe's page. */
|
|
121
|
+
export function parentPage(cat, shop, { gone = false, item = null, name = '', token = null, open = true, paid = false, said = '' } = {}) {
|
|
122
|
+
const studio = cat.studio?.name ?? 'Studio';
|
|
123
|
+
let body;
|
|
124
|
+
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>';
|
|
125
|
+
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>`;
|
|
126
|
+
else {
|
|
127
|
+
body = `<h1>${esc(name)} asked you for ${esc(item.name)}</h1>
|
|
128
|
+
<p class="lead">${esc(item.name)} costs <b>${esc(money(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>
|
|
129
|
+
<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>
|
|
130
|
+
${said ? `<p class="msg" role="alert">${esc(said)}</p>` : ''}
|
|
131
|
+
${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>
|
|
132
|
+
<button class="btn" type="submit">Pay ${esc(money(item.price, shop.currency))} on Stripe</button></form>` : '<p>The shop is not open just now. Try this link again later.</p>'}
|
|
133
|
+
<p class="note">Unused items can be refunded within ${shop.refundDays} days: <a href="/shop/refunds/">refunds</a>.</p>`;
|
|
134
|
+
}
|
|
135
|
+
const main = `<section class="shop"><p class="kicker">${esc(studio)}</p>${body}</section>`;
|
|
136
|
+
return layout(cat, { title: `For ${name || 'a player'} · ${studio}`, page: 'shop', head: `<style>${SHOP_CSS}</style><meta name="robots" content="noindex">`, main, status: gone ? 410 : 200, extraHeaders: { ...NOINDEX, 'referrer-policy': 'no-referrer' } });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* /_homie/shop.js: /shop/ (the list, sign in, the age question, Buy, ask a grown-up), /shop/thanks (waits for paid),
|
|
141
|
+
* and the account page's purchases card (badges, orders, the self-serve refund).
|
|
142
|
+
*/
|
|
143
|
+
export const SHOP_JS = String.raw`(function () {
|
|
144
|
+
'use strict';
|
|
145
|
+
var bootEl = document.getElementById('shop-boot');
|
|
146
|
+
var boot = {};
|
|
147
|
+
try { boot = bootEl ? JSON.parse(bootEl.textContent) : {}; } catch (e) { boot = {}; }
|
|
148
|
+
var WAY = ${JSON.stringify(WAY_LINES)};
|
|
149
|
+
function $(s, r) { return (r || document).querySelector(s); }
|
|
150
|
+
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; }
|
|
151
|
+
function api(method, path, body) {
|
|
152
|
+
var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
|
|
153
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
154
|
+
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
155
|
+
}
|
|
156
|
+
function say(text, bad) { var m = $('[data-shop-msg]'); if (m) { m.textContent = text || ''; m.style.color = bad ? '#ff8a80' : ''; } }
|
|
157
|
+
var q = boot.game ? '?game=' + encodeURIComponent(boot.game) : '';
|
|
158
|
+
|
|
159
|
+
/* ---------------- /shop/ */
|
|
160
|
+
var list = $('[data-shop-items]');
|
|
161
|
+
function draw(s) {
|
|
162
|
+
list.textContent = '';
|
|
163
|
+
if (!s.ok) { list.appendChild(el('p', null, s.message || 'The shop did not answer.')); return; }
|
|
164
|
+
if (!s.open) { list.appendChild(el('p', null, s.kids ? 'Nothing is sold here.' : 'The shop is not open yet.')); return; }
|
|
165
|
+
var age = $('[data-shop-age]');
|
|
166
|
+
var needsAge = s.items.some(function (i) { return i.way === 'age-question'; });
|
|
167
|
+
if (age) {
|
|
168
|
+
age.hidden = !needsAge; age.textContent = '';
|
|
169
|
+
if (needsAge) {
|
|
170
|
+
age.className = 'age';
|
|
171
|
+
age.appendChild(el('p', null, 'What year were you born? (Asked once, for this account.)'));
|
|
172
|
+
var sel = el('select', { 'aria-label': 'Year you were born' });
|
|
173
|
+
var blank = el('option', { value: '' }, 'Year'); blank.selected = true; sel.appendChild(blank);
|
|
174
|
+
(boot.years || []).forEach(function (y) { sel.appendChild(el('option', { value: String(y) }, String(y))); });
|
|
175
|
+
var ok = el('button', { type: 'button', class: 'btn' }, 'Done');
|
|
176
|
+
ok.addEventListener('click', function () {
|
|
177
|
+
if (!sel.value) { say('Pick a year.', true); return; }
|
|
178
|
+
api('POST', '/api/shop/age', { year: Number(sel.value) }).then(function (r) { if (!r.ok) throw new Error(r.message); say(''); load(); }).catch(function (e) { say(e.message, true); });
|
|
179
|
+
});
|
|
180
|
+
age.appendChild(sel); age.appendChild(ok);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
s.items.forEach(function (i) {
|
|
184
|
+
var card = el('article', { class: 'item', id: 'item-' + i.id });
|
|
185
|
+
card.appendChild(el('h2', null, i.name));
|
|
186
|
+
card.appendChild(el('span', { class: 'price' }, i.shown));
|
|
187
|
+
if (i.blurb) card.appendChild(el('p', null, i.blurb));
|
|
188
|
+
if (i.days) card.appendChild(el('p', null, 'Lasts ' + i.days + ' days.'));
|
|
189
|
+
if (i.ends) card.appendChild(el('p', null, 'On sale until ' + new Date(i.ends).toISOString().slice(0, 10) + '.'));
|
|
190
|
+
var act = el('div', { class: 'act' });
|
|
191
|
+
if (i.way === 'checkout') {
|
|
192
|
+
var amount = null;
|
|
193
|
+
if (i.kind === 'tip') {
|
|
194
|
+
amount = el('select', { 'aria-label': 'How much' });
|
|
195
|
+
[i.min, i.min * 2, i.min * 5].filter(function (v, k, a) { return v <= i.max && a.indexOf(v) === k; }).forEach(function (v) { amount.appendChild(el('option', { value: String(v) }, (v / 100).toFixed(2) + ' ' + s.currency.toUpperCase())); });
|
|
196
|
+
act.appendChild(amount);
|
|
197
|
+
}
|
|
198
|
+
var b = el('button', { type: 'button', class: 'buy' }, i.kind === 'tip' ? 'Give on Stripe' : 'Buy on Stripe');
|
|
199
|
+
b.addEventListener('click', function () {
|
|
200
|
+
b.disabled = true; say('Opening Stripe…');
|
|
201
|
+
api('POST', '/api/shop/buy', { item: i.id, game: boot.game || undefined, amount: amount ? Number(amount.value) : undefined }).then(function (r) {
|
|
202
|
+
if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
|
|
203
|
+
location.href = r.url;
|
|
204
|
+
}).catch(function (e) { b.disabled = false; say(e.message, true); });
|
|
205
|
+
});
|
|
206
|
+
act.appendChild(b);
|
|
207
|
+
} else if (i.way === 'owned') {
|
|
208
|
+
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
209
|
+
} else if (i.way === 'make-an-account') {
|
|
210
|
+
var a = el('a', { class: 'plain', href: '/account/?next=' + encodeURIComponent('/shop/' + q) }, 'Make an account');
|
|
211
|
+
act.appendChild(a); act.appendChild(el('span', { class: 'why' }, WAY[i.way]));
|
|
212
|
+
} else if (i.way === 'ask-a-parent') {
|
|
213
|
+
var p = el('button', { type: 'button', class: 'plain' }, 'Get a link for a parent');
|
|
214
|
+
p.addEventListener('click', function () {
|
|
215
|
+
api('POST', '/api/shop/parent', { item: i.id, game: boot.game || undefined }).then(function (r) {
|
|
216
|
+
if (!r.ok) throw new Error(r.message);
|
|
217
|
+
var box = el('div', { class: 'link' }, r.link);
|
|
218
|
+
act.appendChild(box);
|
|
219
|
+
if (navigator.share) navigator.share({ title: i.name, url: r.link }).catch(function () {});
|
|
220
|
+
else if (navigator.clipboard) navigator.clipboard.writeText(r.link).then(function () { say('Copied. Send it to a parent or guardian; it works for a week.'); }).catch(function () {});
|
|
221
|
+
}).catch(function (e) { say(e.message, true); });
|
|
222
|
+
});
|
|
223
|
+
act.appendChild(p); act.appendChild(el('span', { class: 'why' }, WAY[i.way]));
|
|
224
|
+
} else if (i.way !== 'age-question') {
|
|
225
|
+
act.appendChild(el('span', { class: 'why' }, WAY[i.way] || ''));
|
|
226
|
+
}
|
|
227
|
+
card.appendChild(act);
|
|
228
|
+
list.appendChild(card);
|
|
229
|
+
});
|
|
230
|
+
if (!s.items.length) list.appendChild(el('p', null, 'Nothing for sale here right now.'));
|
|
231
|
+
if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
|
|
232
|
+
}
|
|
233
|
+
function load() { api('GET', '/api/shop' + q).then(draw).catch(function () { draw({ ok: false }); }); }
|
|
234
|
+
if (list) { if (/[?&]cancelled=1/.test(location.search)) say('Nothing was charged.'); load(); }
|
|
235
|
+
|
|
236
|
+
/* ---------------- /shop/thanks */
|
|
237
|
+
if (boot.thanks) {
|
|
238
|
+
var tries = 0;
|
|
239
|
+
(function poll() {
|
|
240
|
+
tries += 1;
|
|
241
|
+
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
242
|
+
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; }
|
|
243
|
+
if (tries < 40) setTimeout(poll, 1500);
|
|
244
|
+
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.';
|
|
245
|
+
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
246
|
+
})();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ---------------- the account page's purchases card */
|
|
250
|
+
var card = $('[data-shop-card]');
|
|
251
|
+
if (card) {
|
|
252
|
+
api('GET', '/api/shop/mine').then(function (r) {
|
|
253
|
+
if (!r.ok || (!r.orders.length && !r.owns.length)) return;
|
|
254
|
+
card.hidden = false;
|
|
255
|
+
var body = $('[data-shop-body]', card);
|
|
256
|
+
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); }
|
|
257
|
+
var ul = el('ul');
|
|
258
|
+
r.orders.forEach(function (o) {
|
|
259
|
+
var li = el('li', null, o.name + ' · ' + o.shown + ' · ' + new Date(o.createdAt).toISOString().slice(0, 10) + ' · ' + o.status + ' ');
|
|
260
|
+
if (o.refundable) {
|
|
261
|
+
var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
|
|
262
|
+
b.addEventListener('click', function () {
|
|
263
|
+
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;
|
|
264
|
+
b.disabled = true;
|
|
265
|
+
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); });
|
|
266
|
+
});
|
|
267
|
+
li.appendChild(b);
|
|
268
|
+
}
|
|
269
|
+
ul.appendChild(li);
|
|
270
|
+
});
|
|
271
|
+
body.appendChild(ul);
|
|
272
|
+
body.appendChild(el('p', { class: 'note' }, 'Unused items can be refunded within ' + r.refundDays + ' days. Receipts come from Stripe by email.'));
|
|
273
|
+
}).catch(function () {});
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
`;
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------------ the play shell's store sheet */
|
|
279
|
+
|
|
280
|
+
export const SHOP_SHELL_CSS = `
|
|
281
|
+
.shopsheet { position: fixed; inset: 0; z-index: 21; display: grid; place-items: end center; padding: 12px; background: rgba(2,4,10,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); touch-action: manipulation; -webkit-user-select: text; user-select: text; }
|
|
282
|
+
.shopsheet .box { box-sizing: border-box; width: min(440px, 100%); max-height: min(78vh, 640px); overflow: auto; padding: 18px; border-radius: 20px; background: rgba(10,14,24,.98); border: 1px solid rgba(255,255,255,.16); color: #eef1f8; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.6); }
|
|
283
|
+
@media (min-width: 700px) { .shopsheet { place-items: center; } }
|
|
284
|
+
.shopsheet h2 { margin: 0 0 4px; font-size: 19px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
|
285
|
+
.shopsheet h2 button { background: none; border: 0; color: #aab3c7; font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; width: 40px; height: 40px; }
|
|
286
|
+
.shopsheet .sub { margin: 0 0 12px; color: #aab3c7; font-size: 13px; }
|
|
287
|
+
.shopsheet .it { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: center; padding: 12px 0; border-top: 1px solid rgba(255,255,255,.1); }
|
|
288
|
+
.shopsheet .it b { font-size: 15.5px; }
|
|
289
|
+
.shopsheet .it .pr { font-weight: 800; }
|
|
290
|
+
.shopsheet .it small { grid-column: 1 / -1; color: #b9c2d6; }
|
|
291
|
+
.shopsheet .it .go { grid-column: 1 / -1; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
292
|
+
.shopsheet .it .go button, .shopsheet .it .go a { min-height: 42px; padding: 0 14px; border-radius: 11px; border: 1px solid rgba(255,255,255,.2); background: transparent; color: inherit; font: 700 14px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
|
|
293
|
+
.shopsheet .it .go .buy { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
294
|
+
.shopsheet .it .go span { color: #b9c2d6; font-size: 13px; }
|
|
295
|
+
.shopsheet .qr { width: min(260px, 60vmin); margin: 8px auto; border-radius: 12px; overflow: hidden; background: #fff; }
|
|
296
|
+
.shopsheet .qr svg { display: block; width: 100%; height: 100%; }
|
|
297
|
+
.shopsheet .said { min-height: 1.3em; margin: 8px 0 0; font-weight: 600; }
|
|
298
|
+
.shopsheet .foot { margin: 12px 0 0; font-size: 12.5px; color: #8f99b0; }
|
|
299
|
+
.shopsheet .foot a { color: #c9d1e3; }
|
|
300
|
+
`;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The play shell's half of `@homie-rocks/studio/shop` (shop/SHOP.md): answers the game's `homie-shop` messages, only
|
|
304
|
+
* from its own frame and only for THIS game, and shows the store sheet when the game (or the room button) asks. On a
|
|
305
|
+
* television the sheet is only a code to scan and buy on a phone; on a kids server there is no shop at all.
|
|
306
|
+
*/
|
|
307
|
+
export const SHOP_SHELL_JS = String.raw`(function () {
|
|
308
|
+
'use strict';
|
|
309
|
+
var boot = window.__HOMIE_PLAY || {};
|
|
310
|
+
var cfg = boot.shop;
|
|
311
|
+
var frame = document.querySelector('iframe.game');
|
|
312
|
+
if (!frame) return;
|
|
313
|
+
var GAME = boot.game;
|
|
314
|
+
var SERVER = boot.server ? boot.server.id : 'public';
|
|
315
|
+
var kids = !!(boot.server && boot.server.kids);
|
|
316
|
+
var screen = !!boot.screen;
|
|
317
|
+
var WAY = ${JSON.stringify(WAY_LINES)};
|
|
318
|
+
var owns = [];
|
|
319
|
+
var lastSeen = '';
|
|
320
|
+
function api(method, path, body) {
|
|
321
|
+
var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
|
|
322
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
323
|
+
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
324
|
+
}
|
|
325
|
+
function post(m) { try { frame.contentWindow.postMessage(Object.assign({ t: 'homie-shop' }, m), '*'); } catch (e) {} }
|
|
326
|
+
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; }
|
|
327
|
+
var q = '?game=' + encodeURIComponent(GAME) + '&server=' + encodeURIComponent(SERVER);
|
|
328
|
+
function refresh() {
|
|
329
|
+
if (!cfg || kids || screen) return Promise.resolve(owns);
|
|
330
|
+
return api('GET', '/api/player/owns' + q).then(function (r) {
|
|
331
|
+
var next = (r && r.owns) || [];
|
|
332
|
+
var key = next.slice().sort().join(',');
|
|
333
|
+
if (key !== lastSeen) { var fresh = lastSeen !== '' || owns.length === 0; lastSeen = key; owns = next; post({ ev: 'owns', owns: owns, fresh: fresh }); }
|
|
334
|
+
return owns;
|
|
335
|
+
}).catch(function () { return owns; });
|
|
336
|
+
}
|
|
337
|
+
var watching = 0;
|
|
338
|
+
/** After a checkout opens in a new tab: look again when this tab comes back, and every few seconds for two minutes. */
|
|
339
|
+
function watch() {
|
|
340
|
+
watching = Date.now();
|
|
341
|
+
(function tick() { if (Date.now() - watching > 120000) return; refresh().then(function () { setTimeout(tick, 3000); }); })();
|
|
342
|
+
}
|
|
343
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden && watching) refresh(); });
|
|
344
|
+
|
|
345
|
+
var sheet = null;
|
|
346
|
+
function close() { if (sheet) { sheet.remove(); sheet = null; post({ ev: 'closed' }); } }
|
|
347
|
+
function open(want) {
|
|
348
|
+
close();
|
|
349
|
+
sheet = el('div', { class: 'shopsheet', role: 'dialog', 'aria-label': 'Shop' });
|
|
350
|
+
var box = el('div', { class: 'box' });
|
|
351
|
+
var h = el('h2', null, ''); h.appendChild(el('span', null, (boot.name || 'Game') + ': shop'));
|
|
352
|
+
var x = el('button', { type: 'button', 'aria-label': 'Close' }, '×'); x.addEventListener('click', close); h.appendChild(x);
|
|
353
|
+
box.appendChild(h);
|
|
354
|
+
sheet.appendChild(box);
|
|
355
|
+
sheet.addEventListener('click', function (e) { if (e.target === sheet) close(); });
|
|
356
|
+
document.body.appendChild(sheet);
|
|
357
|
+
if (kids || !cfg) { box.appendChild(el('p', { class: 'sub' }, 'Nothing is sold here.')); return; }
|
|
358
|
+
if (screen) {
|
|
359
|
+
// The television never sells: a code to scan and buy on a phone, in the phone's own browser.
|
|
360
|
+
box.appendChild(el('p', { class: 'sub' }, 'Buy on your phone: scan this code. Nothing is sold on this screen.'));
|
|
361
|
+
if (cfg.qr) { var qr = el('div', { class: 'qr' }); qr.innerHTML = cfg.qr; box.appendChild(qr); }
|
|
362
|
+
if (cfg.url) box.appendChild(el('p', { class: 'foot' }, cfg.url.replace(/^https?:\/\//, '')));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
box.appendChild(el('p', { class: 'sub' }, 'Real money, from the studio itself. Each purchase opens Stripe\'s own page in a new tab; your game keeps going here.'));
|
|
366
|
+
var said = el('p', { class: 'said', role: 'status' });
|
|
367
|
+
var listBox = el('div'); box.appendChild(listBox); box.appendChild(said);
|
|
368
|
+
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(' · unused items can be refunded from your account page.')); box.appendChild(foot);
|
|
369
|
+
api('GET', '/api/shop' + q).then(function (s) {
|
|
370
|
+
if (!s.ok || !s.open) { listBox.appendChild(el('p', { class: 'sub' }, s.kids ? 'Nothing is sold here.' : 'The shop is not open yet.')); return; }
|
|
371
|
+
s.items.forEach(function (i) {
|
|
372
|
+
var row = el('div', { class: 'it' });
|
|
373
|
+
row.appendChild(el('b', null, i.name)); row.appendChild(el('span', { class: 'pr' }, i.shown));
|
|
374
|
+
if (i.blurb) row.appendChild(el('small', null, i.blurb));
|
|
375
|
+
var go = el('div', { class: 'go' });
|
|
376
|
+
if (i.way === 'checkout' && i.kind !== 'tip') {
|
|
377
|
+
var b = el('button', { type: 'button', class: 'buy' }, 'Buy on Stripe');
|
|
378
|
+
b.addEventListener('click', function () {
|
|
379
|
+
// The new tab opens on the tap (a browser allows that), then goes to Stripe's page once it is made.
|
|
380
|
+
var tab = window.open('', '_blank');
|
|
381
|
+
b.disabled = true; said.textContent = 'Opening Stripe…';
|
|
382
|
+
api('POST', '/api/shop/buy', { item: i.id, game: GAME, server: SERVER }).then(function (r) {
|
|
383
|
+
if (!r.ok || !r.url) throw new Error(r.message || 'That did not work.');
|
|
384
|
+
if (tab) tab.location.href = r.url; else location.assign(r.url);
|
|
385
|
+
said.textContent = 'Finish on Stripe\'s page. It shows up here once it is paid.';
|
|
386
|
+
watch();
|
|
387
|
+
}).catch(function (e) { if (tab) tab.close(); b.disabled = false; said.textContent = e.message; });
|
|
388
|
+
});
|
|
389
|
+
go.appendChild(b);
|
|
390
|
+
} else if (i.way === 'owned') go.appendChild(el('span', null, 'Yours ✓'));
|
|
391
|
+
else {
|
|
392
|
+
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');
|
|
393
|
+
if (i.way === 'make-an-account' || i.way === 'age-question' || i.way === 'ask-a-parent' || i.kind === 'tip') { go.appendChild(a); a.addEventListener('click', watch); }
|
|
394
|
+
go.appendChild(el('span', null, WAY[i.way] || ''));
|
|
395
|
+
}
|
|
396
|
+
row.appendChild(go);
|
|
397
|
+
if (want && want === i.id) setTimeout(function () { row.scrollIntoView && row.scrollIntoView({ block: 'center' }); }, 0);
|
|
398
|
+
listBox.appendChild(row);
|
|
399
|
+
});
|
|
400
|
+
if (!s.items.length) listBox.appendChild(el('p', { class: 'sub' }, 'Nothing for sale here right now.'));
|
|
401
|
+
}).catch(function () { listBox.appendChild(el('p', { class: 'sub' }, 'The shop did not answer.')); });
|
|
402
|
+
}
|
|
403
|
+
var opener = document.querySelector('[data-shop-open]');
|
|
404
|
+
if (opener) opener.addEventListener('click', function () { open(null); });
|
|
405
|
+
var state = window.__shell || (window.__shell = {});
|
|
406
|
+
state.shop = { open: open, close: close, refresh: refresh, get owns() { return owns.slice(); } };
|
|
407
|
+
|
|
408
|
+
window.addEventListener('message', function (ev) {
|
|
409
|
+
if (ev.source !== frame.contentWindow) return;
|
|
410
|
+
var m = ev.data;
|
|
411
|
+
if (!m || typeof m !== 'object' || m.t !== 'homie-shop') return;
|
|
412
|
+
var reply = function (body) { post(Object.assign({ q: m.q }, body)); };
|
|
413
|
+
if (m.op === 'hello') {
|
|
414
|
+
if (!cfg || kids) return reply({ ok: true, open: false, kids: kids, screen: screen, owns: [] });
|
|
415
|
+
return refresh().then(function () { reply({ ok: true, open: true, kids: false, screen: screen, owns: owns }); });
|
|
416
|
+
}
|
|
417
|
+
if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
|
|
418
|
+
if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
|
|
419
|
+
if (m.op === 'close') { close(); return reply({ ok: true }); }
|
|
420
|
+
if (m.op === 'used') {
|
|
421
|
+
var key = String(m.key || '');
|
|
422
|
+
if (!/^[a-z0-9][a-z0-9_.:-]{0,63}$/.test(key) || owns.indexOf(key) < 0) return reply({ ok: false, error: 'key' });
|
|
423
|
+
return api('POST', '/api/shop/used', { key: key }).then(function (r) { reply({ ok: !!r.ok }); }).catch(function () { reply({ ok: false }); });
|
|
424
|
+
}
|
|
425
|
+
reply({ ok: false, error: 'op' });
|
|
426
|
+
});
|
|
427
|
+
if (cfg && !kids && !screen) refresh();
|
|
428
|
+
})();
|
|
429
|
+
`;
|
|
430
|
+
|
|
431
|
+
/* ------------------------------------------------------------------ the office's shop page */
|
|
432
|
+
|
|
433
|
+
export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
434
|
+
'use strict';
|
|
435
|
+
function $(s) { return document.querySelector(s); }
|
|
436
|
+
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; }
|
|
437
|
+
function api(method, path, body) {
|
|
438
|
+
var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {} };
|
|
439
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
440
|
+
return fetch(path, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: 'the site answered ' + r.status }; }); });
|
|
441
|
+
}
|
|
442
|
+
function cents(n, c) { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: String(c || 'usd').toUpperCase() }).format(n / 100); } catch (e) { return (n / 100).toFixed(2); } }
|
|
443
|
+
function toast(t) { var x = $('#toast'); x.textContent = t; x.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(function () { x.hidden = true; }, 4000); }
|
|
444
|
+
function link(href, text) { var a = el('a', { href: href, target: '_blank', rel: 'noopener' }, text); return a; }
|
|
445
|
+
function load() {
|
|
446
|
+
api('GET', '/_studio/api/shop').then(function (s) {
|
|
447
|
+
var top = $('#state'); top.textContent = '';
|
|
448
|
+
if (!s.ok) { top.appendChild(el('p', { class: 'empty' }, s.message || 'The shop did not answer.')); return; }
|
|
449
|
+
top.appendChild(el('p', null, s.ready ? ('Open' + (s.mode === 'test' ? ' in TEST MODE (no real money)' : ', live') + (s.shop ? ', till: ' + s.shop.till : '')) : 'Not selling yet.'));
|
|
450
|
+
if (s.missing.length) { var ul = el('ul'); s.missing.forEach(function (m) { ul.appendChild(el('li', null, m.words)); }); top.appendChild(ul); }
|
|
451
|
+
var links = el('p', { class: 'links' });
|
|
452
|
+
[['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])); });
|
|
453
|
+
links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
|
|
454
|
+
top.appendChild(links);
|
|
455
|
+
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.'));
|
|
456
|
+
var ord = $('#orders'); ord.textContent = '';
|
|
457
|
+
if (!s.orders.length) ord.appendChild(el('p', { class: 'empty' }, 'No orders yet.'));
|
|
458
|
+
s.orders.forEach(function (o) {
|
|
459
|
+
var row = el('div', { class: 'row' });
|
|
460
|
+
row.appendChild(el('span', null, new Date(o.createdAt).toISOString().slice(0, 16).replace('T', ' ')));
|
|
461
|
+
row.appendChild(el('b', null, o.name + ' · ' + o.shown));
|
|
462
|
+
row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
|
|
463
|
+
row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '')));
|
|
464
|
+
if (o.stripe) row.appendChild(link(o.stripe, 'In Stripe'));
|
|
465
|
+
if (o.dispute) row.appendChild(link(o.dispute, 'The dispute'));
|
|
466
|
+
if (o.refundable) {
|
|
467
|
+
var b = el('button', { type: 'button', class: 'ghost small' }, 'Refund');
|
|
468
|
+
b.addEventListener('click', function () {
|
|
469
|
+
if (!confirm('Refund ' + o.name + ' (' + o.shown + ') in full? The buyer gets the money back and the item leaves their account.')) return;
|
|
470
|
+
b.disabled = true;
|
|
471
|
+
api('POST', '/_studio/api/shop/refund', { order: o.id }).then(function (r) { if (!r.ok) throw new Error(r.message || 'That did not work.'); toast('Refunded.'); load(); }).catch(function (e) { b.disabled = false; toast(e.message); });
|
|
472
|
+
});
|
|
473
|
+
row.appendChild(b);
|
|
474
|
+
}
|
|
475
|
+
ord.appendChild(row);
|
|
476
|
+
});
|
|
477
|
+
var ref = $('#referrals'); ref.textContent = '';
|
|
478
|
+
var owe = s.referrals.owe || [];
|
|
479
|
+
ref.appendChild(el('h3', null, 'You owe referrers'));
|
|
480
|
+
if (!owe.length) ref.appendChild(el('p', { class: 'dim' }, 'Nothing: no sale came from another site\'s link yet.'));
|
|
481
|
+
owe.forEach(function (b) {
|
|
482
|
+
var row = el('div', { class: 'row' });
|
|
483
|
+
row.appendChild(el('b', null, b.via));
|
|
484
|
+
row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.lines.length + ' sale(s)'));
|
|
485
|
+
if (b.due > 0) {
|
|
486
|
+
var m = el('button', { type: 'button', class: 'ghost small' }, 'Mark paid');
|
|
487
|
+
m.addEventListener('click', function () {
|
|
488
|
+
var ref0 = prompt('What paid it (an invoice number, a PayPal reference)?'); if (ref0 === null) return;
|
|
489
|
+
api('POST', '/_studio/api/shop/settle', { via: b.via, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
|
|
490
|
+
});
|
|
491
|
+
row.appendChild(m);
|
|
492
|
+
}
|
|
493
|
+
ref.appendChild(row);
|
|
494
|
+
});
|
|
495
|
+
var send = el('button', { type: 'button', class: 'ghost small' }, 'Send last month\'s signed statements');
|
|
496
|
+
send.addEventListener('click', function () { api('POST', '/_studio/api/shop/statements/send', {}).then(function (r) { toast(r.ok ? 'Sent ' + r.sent.filter(function (x) { return x.ok; }).length + ' of ' + r.sent.length + '.' : (r.message || 'Not sent.')); }); });
|
|
497
|
+
if (owe.length) ref.appendChild(send);
|
|
498
|
+
ref.appendChild(el('h3', null, 'Owed to you'));
|
|
499
|
+
var inn = s.referrals.owedToUs || [];
|
|
500
|
+
if (!inn.length) ref.appendChild(el('p', { class: 'dim' }, 'No statements yet. When another studio sells to a player you sent, it sends a signed statement here.'));
|
|
501
|
+
inn.forEach(function (x) {
|
|
502
|
+
var row = el('div', { class: 'row' });
|
|
503
|
+
row.appendChild(el('b', null, x.seller.replace(/^https:\/\//, '')));
|
|
504
|
+
row.appendChild(el('span', null, x.period + ': due ' + cents(x.due, x.currency) + ', held ' + cents(x.pending, x.currency)));
|
|
505
|
+
if (x.due > 0) row.appendChild(link(s.referrals.invoice, 'Invoice them in Stripe'));
|
|
506
|
+
ref.appendChild(row);
|
|
507
|
+
});
|
|
508
|
+
}).catch(function () { $('#state').textContent = 'The shop did not answer.'; });
|
|
509
|
+
}
|
|
510
|
+
load();
|
|
511
|
+
})();
|
|
512
|
+
`;
|
|
513
|
+
|
|
514
|
+
let officeHash = null;
|
|
515
|
+
/** /_studio/office/shop: the owner's shop. One inline script, allowed by its hash; connect only to this site. */
|
|
516
|
+
export async function officeShopPage(cat) {
|
|
517
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
518
|
+
if (!officeHash) {
|
|
519
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(OFFICE_SHOP_SCRIPT));
|
|
520
|
+
officeHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
|
|
521
|
+
}
|
|
522
|
+
const body = `<header class="top"><h1>${esc(name)} <small>Shop</small></h1><div class="links"><a href="/_studio/office">Office</a><a href="/_studio/stats">Stats</a></div></header>
|
|
523
|
+
<style>.row{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}.row b{font-weight:700}.st{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2)}.st-refunded,.st-lost{opacity:.7}.st-disputed{border-color:#ff8a80;color:#ff8a80}h3{margin:22px 0 6px;font-size:15px}.links a{margin-right:2px}</style>
|
|
524
|
+
<div class="game" style="padding:14px 16px" id="state"><p class="empty">Reading the shop…</p></div>
|
|
525
|
+
<div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
|
|
526
|
+
<div class="game" style="padding:6px 16px 12px" id="referrals"></div>
|
|
527
|
+
<div class="toast" id="toast" role="status" hidden></div>
|
|
528
|
+
<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>`;
|
|
529
|
+
return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
|
|
530
|
+
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'` },
|
|
531
|
+
});
|
|
532
|
+
}
|