@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.
- package/README.md +37 -1
- package/package.json +1 -1
- 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
|
|
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
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
|
|
6
|
-
*
|
|
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
|
|
26
|
-
* @property {
|
|
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]
|
|
29
|
-
* @property {string} [yearlyNote]
|
|
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
|
-
|
|
62
|
+
studios: [],
|
|
63
|
+
headline: '',
|
|
64
|
+
extras: '',
|
|
65
|
+
monthly: '$8',
|
|
38
66
|
yearly: '$40',
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
?
|
|
66
|
-
:
|
|
143
|
+
? `${verb} — ${config.yearly} / year →`
|
|
144
|
+
: `${verb} — ${config.monthly} / month →`;
|
|
67
145
|
}
|
|
68
146
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
233
|
+
// ---- Showcase: context (opened from a locked studio) -----------------------
|
|
109
234
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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);
|