@legenki/studio-core 0.3.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 +36 -0
  2. package/package.json +1 -1
  3. package/src/paywall.js +315 -72
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@legenki/studio-core",
3
- "version": "0.3.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,12 +25,29 @@ 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.
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.
26
51
  * @property {string} [monthly] Display price, e.g. "$8".
27
52
  * @property {string} [yearly] Display price, e.g. "$40".
28
53
  * @property {string} [yearlyPerMonth] Override; derived from `yearly` if unset.
@@ -34,6 +59,9 @@ const config = {
34
59
  appName: 'Pro',
35
60
  subtitle: '',
36
61
  features: [],
62
+ studios: [],
63
+ headline: '',
64
+ extras: '',
37
65
  monthly: '$8',
38
66
  yearly: '$40',
39
67
  // Left unset by default: both are derived from the prices below, so a price
@@ -43,26 +71,39 @@ const config = {
43
71
  yearlyNote: '',
44
72
  };
45
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
+
46
84
  /**
47
85
  * Yearly savings, derived from the two prices rather than hardcoded.
48
86
  * Falls back to the caller's override when one was supplied.
49
87
  */
50
88
  function yearlyNote() {
51
89
  if (config.yearlyNote) return config.yearlyNote;
52
- const m = Number(String(config.monthly).replace(/[^0-9.]/g, ''));
53
- const y = Number(String(config.yearly).replace(/[^0-9.]/g, ''));
54
- if (!m || !y) return 'Yearly';
55
- const percent = Math.round(((m * 12 - y) / (m * 12)) * 100);
90
+ const percent = yearlySavings();
56
91
  return percent > 0 ? `Yearly · save ${percent}%` : 'Yearly';
57
92
  }
58
93
 
59
- /** Effective monthly cost of the yearly plan, e.g. "$3.33 / month". */
60
- function yearlyPerMonth() {
61
- if (config.yearlyPerMonth) return config.yearlyPerMonth;
62
- const y = Number(String(config.yearly).replace(/[^0-9.]/g, ''));
94
+ /** Effective monthly cost of the yearly plan, e.g. "$3.33". */
95
+ function yearlyMonthlyAmount() {
96
+ const y = amount(config.yearly);
63
97
  if (!y) return '';
64
98
  const symbol = String(config.yearly).replace(/[0-9.,\s]/g, '') || '$';
65
- return `${symbol}${(y / 12).toFixed(2)} / month`;
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` : '';
66
107
  }
67
108
 
68
109
  /**
@@ -72,7 +113,11 @@ function yearlyPerMonth() {
72
113
  */
73
114
  export function configurePaywall(options = {}) {
74
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';
75
119
  if (_modalEl) {
120
+ closePaywall();
76
121
  _modalEl.remove();
77
122
  _modalEl = null;
78
123
  }
@@ -85,96 +130,293 @@ function escapeHtml(value) {
85
130
  );
86
131
  }
87
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
+
88
140
  function ctaLabel() {
141
+ const verb = config.studios.length ? 'Unlock Pro' : 'Subscribe';
89
142
  return _selectedPlan === 'yearly'
90
- ? `Subscribe — ${config.yearly} / year →`
91
- : `Subscribe — ${config.monthly} / month →`;
143
+ ? `${verb} — ${config.yearly} / year →`
144
+ : `${verb} — ${config.monthly} / month →`;
92
145
  }
93
146
 
94
- function getModal() {
95
- 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>`;
96
149
 
97
- _modalEl = document.createElement('div');
98
- _modalEl.id = 'paywall-modal';
99
- _modalEl.setAttribute('role', 'dialog');
100
- _modalEl.setAttribute('aria-modal', 'true');
101
- _modalEl.setAttribute('aria-label', `${config.appName} Pro`);
102
- _modalEl.style.display = 'none';
103
- _modalEl.innerHTML = `
104
- <div class="paywall-backdrop"></div>
105
- <div class="paywall-box">
106
- <button class="paywall-close" aria-label="Close">✕</button>
107
- <div class="paywall-eyebrow">${escapeHtml(config.appName)} Pro</div>
108
- <div class="paywall-title">Unlock everything</div>
109
- <div class="paywall-sub">${escapeHtml(config.subtitle)}</div>
110
- <ul class="paywall-features">
111
- ${config.features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}
112
- </ul>
113
- <div class="paywall-pricing">
114
- <div class="paywall-plan" data-plan="monthly">
115
- <div class="paywall-plan-label">Monthly</div>
116
- <div class="paywall-plan-amount">${escapeHtml(config.monthly)}</div>
117
- <div class="paywall-plan-period">per month</div>
118
- </div>
119
- <div class="paywall-plan selected" data-plan="yearly">
120
- <div class="paywall-plan-label">${escapeHtml(yearlyNote())}</div>
121
- <div class="paywall-plan-amount">${escapeHtml(config.yearly)}</div>
122
- <div class="paywall-plan-period">${escapeHtml(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>
123
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>
124
223
  </div>
125
- <button class="paywall-cta btn btn-accent">${escapeHtml(ctaLabel())}</button>
126
- <div class="paywall-footer">
127
- Cancel anytime · Powered by Stripe ·
128
- 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>
129
227
  </div>
228
+ <button type="button" class="paywall-cta"></button>
130
229
  </div>
131
- `;
230
+ ${footerHtml()}`;
231
+ }
132
232
 
133
- document.body.appendChild(_modalEl);
233
+ // ---- Showcase: context (opened from a locked studio) -----------------------
134
234
 
135
- _modalEl.querySelector('.paywall-backdrop').addEventListener('click', closePaywall);
136
- _modalEl.querySelector('.paywall-close').addEventListener('click', closePaywall);
137
- _modalEl.querySelector('.paywall-signin-link').addEventListener('click', () => {
138
- closePaywall();
139
- signIn();
140
- });
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
+ }
291
+
292
+ // ---- Shared behaviour ------------------------------------------------------
141
293
 
294
+ /** Reflects the selected plan in every place it shows. */
295
+ function paintPlan() {
296
+ if (!_modalEl) return;
142
297
  _modalEl.querySelectorAll('.paywall-plan').forEach((el) => {
143
- el.addEventListener('click', () => {
144
- _selectedPlan = el.dataset.plan;
145
- _modalEl.querySelectorAll('.paywall-plan').forEach((p) => p.classList.remove('selected'));
146
- el.classList.add('selected');
147
- _modalEl.querySelector('.paywall-cta').textContent = ctaLabel();
148
- });
298
+ const on = el.dataset.plan === _selectedPlan;
299
+ el.classList.toggle('selected', on);
300
+ if (el.tagName === 'BUTTON') el.setAttribute('aria-pressed', String(on));
149
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
+ }
150
324
 
151
- _modalEl.querySelector('.paywall-cta').addEventListener('click', async () => {
152
- const base = _selectedPlan === 'yearly' ? STRIPE_YEARLY_URL : STRIPE_MONTHLY_URL;
153
- if (!base) {
154
- console.warn('[paywall] Stripe payment link not configured.');
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;
341
+
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();
360
+ return;
361
+ }
362
+ const plan = target.closest('.paywall-plan');
363
+ if (plan) {
364
+ _selectedPlan = /** @type {HTMLElement} */ (plan).dataset.plan;
365
+ paintPlan();
155
366
  return;
156
367
  }
157
- // client_reference_id is how the Stripe webhook maps the completed checkout
158
- // back to a Supabase user row (see supabase/functions/stripe-webhook).
159
- const session = await getSession();
160
- window.location.href = session?.user
161
- ? `${base}?client_reference_id=${encodeURIComponent(session.user.id)}`
162
- : base;
368
+ if (target.closest('.paywall-cta')) checkout();
163
369
  });
164
370
 
165
371
  return _modalEl;
166
372
  }
167
373
 
168
- 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 = {}) {
169
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
+ }
170
406
  modal.style.display = 'flex';
171
407
  document.body.style.overflow = 'hidden';
408
+ /** @type {HTMLElement|null} */ (modal.querySelector('.paywall-cta'))?.focus({
409
+ preventScroll: true,
410
+ });
172
411
  }
173
412
 
174
413
  export function closePaywall() {
175
- if (!_modalEl) return;
414
+ if (!_modalEl || _modalEl.style.display === 'none') return;
176
415
  _modalEl.style.display = 'none';
177
416
  document.body.style.overflow = '';
417
+ document.removeEventListener('keydown', onKeydown);
418
+ _returnFocus?.focus?.({ preventScroll: true });
419
+ _returnFocus = null;
178
420
  }
179
421
 
180
422
  /**
@@ -193,6 +435,7 @@ export function handleCheckoutSuccess(refreshFn) {
193
435
  function showToast(message) {
194
436
  const toast = document.createElement('div');
195
437
  toast.className = 'paywall-toast';
438
+ toast.setAttribute('role', 'status');
196
439
  toast.textContent = message;
197
440
  document.body.appendChild(toast);
198
441
  setTimeout(() => toast.classList.add('visible'), 10);