@legenki/studio-core 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +37 -1
  2. package/package.json +1 -1
  3. package/src/paywall.js +337 -69
package/README.md CHANGED
@@ -104,6 +104,42 @@ back to a user row.
104
104
 
105
105
  The modal expects `.paywall-*` styles from the host app's stylesheet.
106
106
 
107
+ #### Showcase layout (0.4+)
108
+
109
+ Pass `studios` to replace the feature list with previews. Opened from a locked
110
+ studio, the paywall leads with that studio and lists the rest as included;
111
+ opened from anywhere else (or with an id it does not know), it shows a gallery
112
+ of all of them. Apps that pass no `studios` keep the original layout and
113
+ markup, so they need no restyle to upgrade.
114
+
115
+ ```js
116
+ configurePaywall({
117
+ appName: 'Grafema',
118
+ headline: 'Letters that *move*, *scatter* and *stamp*.', // *word* → <em>
119
+ subtitle: 'Four more studios for your type — plus MP4 video and SVG export.',
120
+ extras: 'MP4 and SVG export', // context view: "…and three more studios, with MP4 and SVG export."
121
+ studios: [
122
+ {
123
+ id: 'textura', // matched against openPaywall({ studio })
124
+ name: 'TEXTURA',
125
+ tagline: 'Kinetic type patterns',
126
+ blurb: 'Turn text into moving patterns and export the loop.',
127
+ image: texturaUrl, // gallery tile and thumbnail (landscape)
128
+ heroImage: texturaHeroUrl, // context view (portrait); falls back to image
129
+ },
130
+ ],
131
+ monthly: '$8',
132
+ yearly: '$40',
133
+ });
134
+
135
+ openPaywall({ studio: 'textura' }); // context view
136
+ openPaywall(); // gallery
137
+ ```
138
+
139
+ Both views render `.paywall-box--context` / `.paywall-box--gallery`; Grafema's
140
+ `src/css/modules/auth.css` has a complete stylesheet for them. The modal closes
141
+ on Escape and returns focus to where it was opened from.
142
+
107
143
  ## Supabase keepalive
108
144
 
109
145
  `.github/workflows/supabase-keepalive.yml` queries the shared Supabase project
@@ -134,7 +170,7 @@ npm version patch # or minor / major
134
170
  git push && git push --tags
135
171
  ```
136
172
 
137
- That runs lint and tests, then publishes to npm with provenance. Needs an
173
+ That runs lint and tests, then publishes to npm. Needs an
138
174
  `NPM_TOKEN` repo secret.
139
175
 
140
176
  Because all four studios pin this package, a breaking change here should be a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@legenki/studio-core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared auth, paywall and utilities for the Legenki browser studios (Grafema, Ritmo, Divix, Lumen).",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/paywall.js CHANGED
@@ -2,8 +2,16 @@
2
2
  * Subscription paywall modal.
3
3
  *
4
4
  * Ported from Ritmo, where the copy was hardcoded. Every app-specific string is
5
- * now passed in via configure(), so Grafema and Ritmo share one implementation
6
- * and one set of styles while advertising their own studios and prices.
5
+ * now passed in via configure(), so the studios share one implementation while
6
+ * advertising their own products and prices.
7
+ *
8
+ * Two layouts:
9
+ * - legacy: title, feature bullets, two plan cards. Used when the app passes
10
+ * no `studios`, so apps that have not restyled keep working unchanged.
11
+ * - showcase: used when the app passes `studios`. `openPaywall({ studio })`
12
+ * from a locked studio shows that studio large with the rest as "also
13
+ * included"; any other entry point (or an unknown id) shows the gallery of
14
+ * all of them.
7
15
  *
8
16
  * The plan prices shown here must match the app's landing page on legenki.com
9
17
  * (src/content/apps/<app>.md) — they are the same offer described twice.
@@ -17,16 +25,33 @@ const STRIPE_YEARLY_URL = import.meta.env.VITE_STRIPE_YEARLY_URL;
17
25
  /** @type {HTMLElement|null} */
18
26
  let _modalEl = null;
19
27
  let _selectedPlan = 'yearly';
28
+ /** Focus to hand back on close, so keyboard users land where they were. */
29
+ let _returnFocus = null;
30
+
31
+ /**
32
+ * @typedef {Object} PaywallStudio
33
+ * @property {string} id Matched against openPaywall({ studio }).
34
+ * @property {string} name e.g. "TEXTURA".
35
+ * @property {string} tagline A few words, e.g. "Kinetic type patterns".
36
+ * @property {string} [blurb] One sentence under the tagline in the context view.
37
+ * @property {string} [image] Preview URL for the gallery tile and thumbnail.
38
+ * @property {string} [heroImage] Portrait preview for the context view; falls
39
+ * back to `image`.
40
+ */
20
41
 
21
42
  /**
22
43
  * @typedef {Object} PaywallConfig
23
44
  * @property {string} appName e.g. "Grafema" — titles the modal.
24
45
  * @property {string} [subtitle] One-line summary of what Pro unlocks.
25
- * @property {string[]} [features] Bullet list shown in the modal.
26
- * @property {string} [monthly] Display price, e.g. "$5".
46
+ * @property {string[]} [features] Bullet list (legacy layout only).
47
+ * @property {PaywallStudio[]} [studios] Enables the showcase layout.
48
+ * @property {string} [headline] Gallery headline; `*word*` is emphasised.
49
+ * @property {string} [extras] What Pro adds besides the studios, e.g.
50
+ * "MP4 and SVG export" — worked into the context view's copy.
51
+ * @property {string} [monthly] Display price, e.g. "$8".
27
52
  * @property {string} [yearly] Display price, e.g. "$40".
28
- * @property {string} [yearlyPerMonth] e.g. "$3.33 / month".
29
- * @property {string} [yearlyNote] e.g. "Yearly · save 33%".
53
+ * @property {string} [yearlyPerMonth] Override; derived from `yearly` if unset.
54
+ * @property {string} [yearlyNote] Override; derived from both prices if unset.
30
55
  */
31
56
 
32
57
  /** @type {Required<PaywallConfig>} */
@@ -34,12 +59,53 @@ const config = {
34
59
  appName: 'Pro',
35
60
  subtitle: '',
36
61
  features: [],
37
- monthly: '$5',
62
+ studios: [],
63
+ headline: '',
64
+ extras: '',
65
+ monthly: '$8',
38
66
  yearly: '$40',
39
- yearlyPerMonth: '$3.33 / month',
40
- yearlyNote: 'Yearly · save 33%',
67
+ // Left unset by default: both are derived from the prices below, so a price
68
+ // change cannot leave a stale "save 33%" sitting next to it. Pass them
69
+ // explicitly only to override the wording.
70
+ yearlyPerMonth: '',
71
+ yearlyNote: '',
41
72
  };
42
73
 
74
+ const amount = (price) => Number(String(price).replace(/[^0-9.]/g, ''));
75
+
76
+ /** Percentage saved by paying yearly, or 0 when it saves nothing. */
77
+ function yearlySavings() {
78
+ const m = amount(config.monthly);
79
+ const y = amount(config.yearly);
80
+ if (!m || !y) return 0;
81
+ return Math.max(0, Math.round(((m * 12 - y) / (m * 12)) * 100));
82
+ }
83
+
84
+ /**
85
+ * Yearly savings, derived from the two prices rather than hardcoded.
86
+ * Falls back to the caller's override when one was supplied.
87
+ */
88
+ function yearlyNote() {
89
+ if (config.yearlyNote) return config.yearlyNote;
90
+ const percent = yearlySavings();
91
+ return percent > 0 ? `Yearly · save ${percent}%` : 'Yearly';
92
+ }
93
+
94
+ /** Effective monthly cost of the yearly plan, e.g. "$3.33". */
95
+ function yearlyMonthlyAmount() {
96
+ const y = amount(config.yearly);
97
+ if (!y) return '';
98
+ const symbol = String(config.yearly).replace(/[0-9.,\s]/g, '') || '$';
99
+ return `${symbol}${(y / 12).toFixed(2)}`;
100
+ }
101
+
102
+ /** e.g. "$3.33 / month". */
103
+ function yearlyPerMonth() {
104
+ if (config.yearlyPerMonth) return config.yearlyPerMonth;
105
+ const per = yearlyMonthlyAmount();
106
+ return per ? `${per} / month` : '';
107
+ }
108
+
43
109
  /**
44
110
  * Sets the app-specific copy. Call once at startup, before openPaywall().
45
111
  * Re-configuring discards any modal already built so the new copy takes effect.
@@ -47,7 +113,11 @@ const config = {
47
113
  */
48
114
  export function configurePaywall(options = {}) {
49
115
  Object.assign(config, options);
116
+ // New copy starts from the default plan. The choice is otherwise kept
117
+ // across opens within a session.
118
+ _selectedPlan = 'yearly';
50
119
  if (_modalEl) {
120
+ closePaywall();
51
121
  _modalEl.remove();
52
122
  _modalEl = null;
53
123
  }
@@ -60,96 +130,293 @@ function escapeHtml(value) {
60
130
  );
61
131
  }
62
132
 
133
+ /** Escapes, then turns `*word*` into <em>word</em>. */
134
+ function emphasis(text) {
135
+ return escapeHtml(text).replace(/\*([^*]+)\*/g, '<em>$1</em>');
136
+ }
137
+
138
+ const NUMBER_WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
139
+
63
140
  function ctaLabel() {
141
+ const verb = config.studios.length ? 'Unlock Pro' : 'Subscribe';
64
142
  return _selectedPlan === 'yearly'
65
- ? `Subscribe — ${config.yearly} / year →`
66
- : `Subscribe — ${config.monthly} / month →`;
143
+ ? `${verb} — ${config.yearly} / year →`
144
+ : `${verb} — ${config.monthly} / month →`;
67
145
  }
68
146
 
69
- function getModal() {
70
- if (_modalEl) return _modalEl;
147
+ const CLOSE_ICON = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
148
+ const LOCK_ICON = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>`;
71
149
 
72
- _modalEl = document.createElement('div');
73
- _modalEl.id = 'paywall-modal';
74
- _modalEl.setAttribute('role', 'dialog');
75
- _modalEl.setAttribute('aria-modal', 'true');
76
- _modalEl.setAttribute('aria-label', `${config.appName} Pro`);
77
- _modalEl.style.display = 'none';
78
- _modalEl.innerHTML = `
79
- <div class="paywall-backdrop"></div>
80
- <div class="paywall-box">
81
- <button class="paywall-close" aria-label="Close">✕</button>
82
- <div class="paywall-eyebrow">${escapeHtml(config.appName)} Pro</div>
83
- <div class="paywall-title">Unlock everything</div>
84
- <div class="paywall-sub">${escapeHtml(config.subtitle)}</div>
85
- <ul class="paywall-features">
86
- ${config.features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}
87
- </ul>
88
- <div class="paywall-pricing">
89
- <div class="paywall-plan" data-plan="monthly">
90
- <div class="paywall-plan-label">Monthly</div>
91
- <div class="paywall-plan-amount">${escapeHtml(config.monthly)}</div>
92
- <div class="paywall-plan-period">per month</div>
93
- </div>
94
- <div class="paywall-plan selected" data-plan="yearly">
95
- <div class="paywall-plan-label">${escapeHtml(config.yearlyNote)}</div>
96
- <div class="paywall-plan-amount">${escapeHtml(config.yearly)}</div>
97
- <div class="paywall-plan-period">${escapeHtml(config.yearlyPerMonth)}</div>
150
+ function footerHtml() {
151
+ return `
152
+ <div class="paywall-footer">
153
+ ${LOCK_ICON}
154
+ <span>Secure checkout by Stripe · Cancel anytime · Already subscribed?</span>
155
+ <button type="button" class="paywall-signin-link">Sign in</button>
156
+ </div>`;
157
+ }
158
+
159
+ function studioImage(url, className) {
160
+ return url ? `<img class="${className}" src="${escapeHtml(url)}" alt="" decoding="async">` : '';
161
+ }
162
+
163
+ // ---- Legacy layout ---------------------------------------------------------
164
+
165
+ function legacyHtml() {
166
+ return `
167
+ <button type="button" class="paywall-close" aria-label="Close">✕</button>
168
+ <div class="paywall-eyebrow">${escapeHtml(config.appName)} Pro</div>
169
+ <div class="paywall-title">Unlock everything</div>
170
+ <div class="paywall-sub">${escapeHtml(config.subtitle)}</div>
171
+ <ul class="paywall-features">
172
+ ${config.features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}
173
+ </ul>
174
+ <div class="paywall-pricing">
175
+ <div class="paywall-plan" data-plan="monthly">
176
+ <div class="paywall-plan-label">Monthly</div>
177
+ <div class="paywall-plan-amount">${escapeHtml(config.monthly)}</div>
178
+ <div class="paywall-plan-period">per month</div>
179
+ </div>
180
+ <div class="paywall-plan" data-plan="yearly">
181
+ <div class="paywall-plan-label">${escapeHtml(yearlyNote())}</div>
182
+ <div class="paywall-plan-amount">${escapeHtml(config.yearly)}</div>
183
+ <div class="paywall-plan-period">${escapeHtml(yearlyPerMonth())}</div>
184
+ </div>
185
+ </div>
186
+ <button type="button" class="paywall-cta btn btn-accent"></button>
187
+ <div class="paywall-footer">
188
+ Cancel anytime · Powered by Stripe ·
189
+ Already subscribed? <a class="paywall-signin-link">Sign in</a>
190
+ </div>`;
191
+ }
192
+
193
+ // ---- Showcase: gallery (no context) ----------------------------------------
194
+
195
+ function galleryHtml() {
196
+ const savings = yearlySavings();
197
+ const tiles = config.studios
198
+ .map(
199
+ (s) => `
200
+ <div class="paywall-tile">
201
+ ${studioImage(s.image, 'paywall-tile-art')}
202
+ <div class="paywall-tile-label">
203
+ <span class="paywall-tile-name">${escapeHtml(s.name)}</span>
204
+ <span class="paywall-tile-tagline">${escapeHtml(s.tagline)}</span>
98
205
  </div>
206
+ </div>`
207
+ )
208
+ .join('');
209
+ return `
210
+ <div class="paywall-head">
211
+ <div class="paywall-eyebrow">${escapeHtml(config.appName)} Pro</div>
212
+ <button type="button" class="paywall-close" aria-label="Close">${CLOSE_ICON}</button>
213
+ </div>
214
+ <h2 class="paywall-headline" id="paywall-heading">${emphasis(config.headline || 'Unlock everything')}</h2>
215
+ ${config.subtitle ? `<p class="paywall-sub">${escapeHtml(config.subtitle)}</p>` : ''}
216
+ <div class="paywall-tiles">${tiles}</div>
217
+ <div class="paywall-buybar">
218
+ <div class="paywall-segmented" role="group" aria-label="Billing period">
219
+ <button type="button" class="paywall-plan" data-plan="yearly">Yearly${
220
+ savings > 0 ? ` <span class="paywall-save">−${savings}%</span>` : ''
221
+ }</button>
222
+ <button type="button" class="paywall-plan" data-plan="monthly">Monthly</button>
99
223
  </div>
100
- <button class="paywall-cta btn btn-accent">${escapeHtml(ctaLabel())}</button>
101
- <div class="paywall-footer">
102
- Cancel anytime · Powered by Stripe ·
103
- Already subscribed? <a class="paywall-signin-link">Sign in</a>
224
+ <div class="paywall-price">
225
+ <span class="paywall-price-amount"></span>
226
+ <span class="paywall-price-note"></span>
104
227
  </div>
228
+ <button type="button" class="paywall-cta"></button>
105
229
  </div>
106
- `;
230
+ ${footerHtml()}`;
231
+ }
107
232
 
108
- document.body.appendChild(_modalEl);
233
+ // ---- Showcase: context (opened from a locked studio) -----------------------
109
234
 
110
- _modalEl.querySelector('.paywall-backdrop').addEventListener('click', closePaywall);
111
- _modalEl.querySelector('.paywall-close').addEventListener('click', closePaywall);
112
- _modalEl.querySelector('.paywall-signin-link').addEventListener('click', () => {
113
- closePaywall();
114
- signIn();
115
- });
235
+ function contextHtml(studio) {
236
+ const others = config.studios.filter((s) => s !== studio);
237
+ const count = NUMBER_WORDS[others.length] ?? String(others.length);
238
+ const noun = others.length === 1 ? 'studio' : 'studios';
239
+ const extras = config.extras ? `, with ${escapeHtml(config.extras)}` : '';
240
+ const savings = yearlySavings();
241
+ const thumbs = others
242
+ .map(
243
+ (s) => `
244
+ <div class="paywall-thumb">
245
+ <div class="paywall-thumb-frame">${studioImage(s.image, 'paywall-thumb-art')}</div>
246
+ <span class="paywall-thumb-name">${escapeHtml(s.name)}</span>
247
+ </div>`
248
+ )
249
+ .join('');
250
+ return `
251
+ <div class="paywall-hero">
252
+ ${studioImage(studio.heroImage || studio.image, 'paywall-hero-art')}
253
+ <div class="paywall-hero-chip">${escapeHtml(studio.name)}</div>
254
+ <div class="paywall-hero-caption">
255
+ <span class="paywall-hero-tagline">${escapeHtml(studio.tagline)}</span>
256
+ ${studio.blurb ? `<span class="paywall-hero-blurb">${escapeHtml(studio.blurb)}</span>` : ''}
257
+ </div>
258
+ </div>
259
+ <div class="paywall-main">
260
+ <div class="paywall-head">
261
+ <div class="paywall-eyebrow">You opened a Pro studio</div>
262
+ <button type="button" class="paywall-close" aria-label="Close">${CLOSE_ICON}</button>
263
+ </div>
264
+ <h2 class="paywall-headline" id="paywall-heading">${escapeHtml(studio.name)} is part of <em>Pro</em>.</h2>
265
+ <p class="paywall-sub">One subscription opens it — and ${count} more ${noun}${extras}.</p>
266
+ ${
267
+ others.length
268
+ ? `<div class="paywall-also">
269
+ <div class="paywall-also-label">Also included</div>
270
+ <div class="paywall-thumbs">${thumbs}</div>
271
+ </div>`
272
+ : ''
273
+ }
274
+ <div class="paywall-plans" role="group" aria-label="Billing period">
275
+ <button type="button" class="paywall-plan paywall-plan-row" data-plan="yearly">
276
+ <span class="paywall-radio" aria-hidden="true"></span>
277
+ <span class="paywall-plan-name">Yearly <span class="paywall-plan-per">${escapeHtml(yearlyPerMonth())}</span></span>
278
+ ${savings > 0 ? `<span class="paywall-badge">Save ${savings}%</span>` : ''}
279
+ <span class="paywall-plan-total">${escapeHtml(config.yearly)}</span>
280
+ </button>
281
+ <button type="button" class="paywall-plan paywall-plan-row" data-plan="monthly">
282
+ <span class="paywall-radio" aria-hidden="true"></span>
283
+ <span class="paywall-plan-name">Monthly</span>
284
+ <span class="paywall-plan-total">${escapeHtml(config.monthly)}</span>
285
+ </button>
286
+ </div>
287
+ <button type="button" class="paywall-cta"></button>
288
+ ${footerHtml()}
289
+ </div>`;
290
+ }
116
291
 
292
+ // ---- Shared behaviour ------------------------------------------------------
293
+
294
+ /** Reflects the selected plan in every place it shows. */
295
+ function paintPlan() {
296
+ if (!_modalEl) return;
117
297
  _modalEl.querySelectorAll('.paywall-plan').forEach((el) => {
118
- el.addEventListener('click', () => {
119
- _selectedPlan = el.dataset.plan;
120
- _modalEl.querySelectorAll('.paywall-plan').forEach((p) => p.classList.remove('selected'));
121
- el.classList.add('selected');
122
- _modalEl.querySelector('.paywall-cta').textContent = ctaLabel();
123
- });
298
+ const on = el.dataset.plan === _selectedPlan;
299
+ el.classList.toggle('selected', on);
300
+ if (el.tagName === 'BUTTON') el.setAttribute('aria-pressed', String(on));
124
301
  });
302
+ const cta = _modalEl.querySelector('.paywall-cta');
303
+ // The gallery prints the price right beside its button, so the button
304
+ // keeps to the action; the other layouts carry the price on the button.
305
+ const gallery = _modalEl.querySelector('.paywall-box--gallery');
306
+ if (cta) cta.textContent = gallery ? 'Unlock Pro →' : ctaLabel();
307
+ const priceAmount = _modalEl.querySelector('.paywall-price-amount');
308
+ const priceNote = _modalEl.querySelector('.paywall-price-note');
309
+ if (priceAmount && priceNote) {
310
+ const yearly = _selectedPlan === 'yearly';
311
+ priceAmount.textContent = yearly ? yearlyMonthlyAmount() : config.monthly;
312
+ priceNote.textContent = yearly
313
+ ? `/ month · ${config.yearly} billed yearly`
314
+ : '/ month · billed monthly';
315
+ }
316
+ }
317
+
318
+ function onKeydown(e) {
319
+ if (e.key === 'Escape') {
320
+ e.preventDefault();
321
+ closePaywall();
322
+ }
323
+ }
324
+
325
+ async function checkout() {
326
+ const base = _selectedPlan === 'yearly' ? STRIPE_YEARLY_URL : STRIPE_MONTHLY_URL;
327
+ if (!base) {
328
+ console.warn('[paywall] Stripe payment link not configured.');
329
+ return;
330
+ }
331
+ // client_reference_id is how the Stripe webhook maps the completed checkout
332
+ // back to a Supabase user row (see supabase/functions/stripe-webhook).
333
+ const session = await getSession();
334
+ window.location.href = session?.user
335
+ ? `${base}?client_reference_id=${encodeURIComponent(session.user.id)}`
336
+ : base;
337
+ }
338
+
339
+ function getModal() {
340
+ if (_modalEl) return _modalEl;
125
341
 
126
- _modalEl.querySelector('.paywall-cta').addEventListener('click', async () => {
127
- const base = _selectedPlan === 'yearly' ? STRIPE_YEARLY_URL : STRIPE_MONTHLY_URL;
128
- if (!base) {
129
- console.warn('[paywall] Stripe payment link not configured.');
342
+ _modalEl = document.createElement('div');
343
+ _modalEl.id = 'paywall-modal';
344
+ _modalEl.style.display = 'none';
345
+ _modalEl.innerHTML = `
346
+ <div class="paywall-backdrop"></div>
347
+ <div class="paywall-box" role="dialog" aria-modal="true"></div>`;
348
+ document.body.appendChild(_modalEl);
349
+
350
+ _modalEl.querySelector('.paywall-backdrop').addEventListener('click', closePaywall);
351
+ // One delegated handler: the box is re-rendered per open (the context view
352
+ // depends on which studio was clicked), so per-element listeners would have
353
+ // to be re-bound every time.
354
+ _modalEl.querySelector('.paywall-box').addEventListener('click', (e) => {
355
+ const target = /** @type {Element} */ (e.target);
356
+ if (target.closest('.paywall-close')) return closePaywall();
357
+ if (target.closest('.paywall-signin-link')) {
358
+ closePaywall();
359
+ signIn();
130
360
  return;
131
361
  }
132
- // client_reference_id is how the Stripe webhook maps the completed checkout
133
- // back to a Supabase user row (see supabase/functions/stripe-webhook).
134
- const session = await getSession();
135
- window.location.href = session?.user
136
- ? `${base}?client_reference_id=${encodeURIComponent(session.user.id)}`
137
- : base;
362
+ const plan = target.closest('.paywall-plan');
363
+ if (plan) {
364
+ _selectedPlan = /** @type {HTMLElement} */ (plan).dataset.plan;
365
+ paintPlan();
366
+ return;
367
+ }
368
+ if (target.closest('.paywall-cta')) checkout();
138
369
  });
139
370
 
140
371
  return _modalEl;
141
372
  }
142
373
 
143
- export function openPaywall() {
374
+ /** Fills the box for this open; returns whether it is the context view. */
375
+ function render(studioId) {
376
+ const box = _modalEl.querySelector('.paywall-box');
377
+ const studio = studioId ? config.studios.find((s) => s.id === studioId) : null;
378
+ let mode = 'legacy';
379
+ if (config.studios.length) mode = studio ? 'context' : 'gallery';
380
+
381
+ box.className = `paywall-box paywall-box--${mode}`;
382
+ box.innerHTML =
383
+ mode === 'context' ? contextHtml(studio) : mode === 'gallery' ? galleryHtml() : legacyHtml();
384
+ if (mode === 'legacy') {
385
+ box.removeAttribute('aria-labelledby');
386
+ box.setAttribute('aria-label', `${config.appName} Pro`);
387
+ } else {
388
+ box.removeAttribute('aria-label');
389
+ box.setAttribute('aria-labelledby', 'paywall-heading');
390
+ }
391
+ paintPlan();
392
+ }
393
+
394
+ /**
395
+ * @param {{ studio?: string }} [options] Pass the id of the locked studio the
396
+ * user tried to open to show it front and centre. Without it (or with an id
397
+ * not in `studios`) the gallery of every studio is shown.
398
+ */
399
+ export function openPaywall(options = {}) {
144
400
  const modal = getModal();
401
+ render(options.studio);
402
+ if (modal.style.display !== 'flex') {
403
+ _returnFocus = /** @type {HTMLElement|null} */ (document.activeElement);
404
+ document.addEventListener('keydown', onKeydown);
405
+ }
145
406
  modal.style.display = 'flex';
146
407
  document.body.style.overflow = 'hidden';
408
+ /** @type {HTMLElement|null} */ (modal.querySelector('.paywall-cta'))?.focus({
409
+ preventScroll: true,
410
+ });
147
411
  }
148
412
 
149
413
  export function closePaywall() {
150
- if (!_modalEl) return;
414
+ if (!_modalEl || _modalEl.style.display === 'none') return;
151
415
  _modalEl.style.display = 'none';
152
416
  document.body.style.overflow = '';
417
+ document.removeEventListener('keydown', onKeydown);
418
+ _returnFocus?.focus?.({ preventScroll: true });
419
+ _returnFocus = null;
153
420
  }
154
421
 
155
422
  /**
@@ -168,6 +435,7 @@ export function handleCheckoutSuccess(refreshFn) {
168
435
  function showToast(message) {
169
436
  const toast = document.createElement('div');
170
437
  toast.className = 'paywall-toast';
438
+ toast.setAttribute('role', 'status');
171
439
  toast.textContent = message;
172
440
  document.body.appendChild(toast);
173
441
  setTimeout(() => toast.classList.add('visible'), 10);