@legenki/studio-core 0.2.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/LICENSE +15 -0
- package/README.md +142 -0
- package/package.json +59 -0
- package/src/auth.js +110 -0
- package/src/datetime.js +14 -0
- package/src/deepMerge.js +27 -0
- package/src/paywall.js +178 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Andy Legenki
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @legenki/studio-core
|
|
2
|
+
|
|
3
|
+
Shared runtime for the Legenki browser studios ([Grafema](https://github.com/legenki/grafema),
|
|
4
|
+
[Ritmo](https://github.com/legenki/ritmo)).
|
|
5
|
+
|
|
6
|
+
This package deliberately holds only the code where a **single source of truth
|
|
7
|
+
matters** — auth, billing and the untrusted-input helpers. The apps' `shared/`
|
|
8
|
+
directories have diverged substantially over time, and unifying all of them is a
|
|
9
|
+
separate refactor. Keeping the surface small keeps the package easy to version.
|
|
10
|
+
|
|
11
|
+
Consumed by **Grafema**, **Ritmo**, **Divix** and **Lumen**.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @legenki/studio-core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or pin to a git tag, which is what the studios currently do — it needs no
|
|
20
|
+
registry auth and makes the exact revision obvious in the lockfile:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"@legenki/studio-core": "github:legenki/studio-core#v0.2.0"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Either way, pin deliberately: an unpinned dependency means a change here can
|
|
27
|
+
break an app's deploy without a commit in that app.
|
|
28
|
+
|
|
29
|
+
### Consumer setup
|
|
30
|
+
|
|
31
|
+
The package ships untranspiled ES modules that read `import.meta.env`, so it
|
|
32
|
+
expects a Vite (or equivalent) consumer. Vitest does not transform
|
|
33
|
+
`node_modules` by default, so tests need it inlined:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// vite.config.js / vitest.config.js
|
|
37
|
+
test: {
|
|
38
|
+
server: { deps: { inline: ['@legenki/studio-core'] } },
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without that, the package sees an undefined `import.meta.env` and throws on
|
|
43
|
+
import.
|
|
44
|
+
|
|
45
|
+
The paywall modal expects `.paywall-*` styles from the host app's stylesheet —
|
|
46
|
+
the package ships no CSS.
|
|
47
|
+
|
|
48
|
+
## Modules
|
|
49
|
+
|
|
50
|
+
| Import | Purpose |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `@legenki/studio-core/auth` | Supabase session, `checkPro()` subscription gate |
|
|
53
|
+
| `@legenki/studio-core/paywall` | Subscription modal, Stripe payment-link redirect |
|
|
54
|
+
| `@legenki/studio-core/deepMerge` | Preset merge, hardened against prototype pollution |
|
|
55
|
+
| `@legenki/studio-core/datetime` | Filename-safe timestamp |
|
|
56
|
+
|
|
57
|
+
### auth
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { initAuth, checkPro, signIn, signOut } from '@legenki/studio-core/auth';
|
|
61
|
+
|
|
62
|
+
initAuth({ onStateChange: (event, session, isPro) => updateUI(isPro) });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY`. **Without them the
|
|
66
|
+
module still imports cleanly** and the app runs signed-out in free mode — this is
|
|
67
|
+
covered by `auth.unconfigured.test.js`, which exists because a module-level crash
|
|
68
|
+
on a build without secrets shipped once before.
|
|
69
|
+
|
|
70
|
+
Set `VITE_DEV_PRO=true` to bypass the gate in local development.
|
|
71
|
+
|
|
72
|
+
Panels are usually built before auth resolves, so `checkPro()` reads false for
|
|
73
|
+
everyone at construction time. Subscribe to be told when the real answer
|
|
74
|
+
arrives, otherwise a subscriber sees the free UI until they reload:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { onProChange } from '@legenki/studio-core/auth';
|
|
78
|
+
|
|
79
|
+
const unsubscribe = onProChange((isPro) => rebuildPanel(isPro));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### paywall
|
|
83
|
+
|
|
84
|
+
Copy is per-app, so configure it once at startup before opening:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { configurePaywall, openPaywall } from '@legenki/studio-core/paywall';
|
|
88
|
+
|
|
89
|
+
configurePaywall({
|
|
90
|
+
appName: 'Grafema',
|
|
91
|
+
subtitle: 'RETICULA, TEXTURA, RASTRO and MUESTRA, plus MP4 and SVG export.',
|
|
92
|
+
features: ['RETICULA — raster grid drawing', 'MP4 and SVG export'],
|
|
93
|
+
monthly: '$5',
|
|
94
|
+
yearly: '$40',
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Prices must match the app's landing page entry in `legenki-site`
|
|
99
|
+
(`src/content/apps/<app>.md`) — the same offer is described in both places.
|
|
100
|
+
|
|
101
|
+
The CTA appends `client_reference_id` (the Supabase user id) to the Stripe
|
|
102
|
+
payment link; the `stripe-webhook` function uses it to map a completed checkout
|
|
103
|
+
back to a user row.
|
|
104
|
+
|
|
105
|
+
The modal expects `.paywall-*` styles from the host app's stylesheet.
|
|
106
|
+
|
|
107
|
+
## Supabase keepalive
|
|
108
|
+
|
|
109
|
+
`.github/workflows/supabase-keepalive.yml` queries the shared Supabase project
|
|
110
|
+
every third day. The free tier pauses a project after ~7 days idle, which would
|
|
111
|
+
take auth and Pro gating down across all four studios until someone restored it
|
|
112
|
+
by hand.
|
|
113
|
+
|
|
114
|
+
It queries through PostgREST rather than just pinging the project URL, because
|
|
115
|
+
the pause check is based on database activity. An anonymous select returns `[]`
|
|
116
|
+
under RLS — that is the healthy response; the point is that Postgres served a
|
|
117
|
+
query. Needs `SUPABASE_URL` and `SUPABASE_ANON_KEY` repo secrets.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install
|
|
123
|
+
npm test
|
|
124
|
+
npm run lint
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Releasing
|
|
128
|
+
|
|
129
|
+
Publishing is tag-driven, and the workflow refuses to publish when the tag and
|
|
130
|
+
`package.json` disagree:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm version patch # or minor / major
|
|
134
|
+
git push && git push --tags
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
That runs lint and tests, then publishes to npm with provenance. Needs an
|
|
138
|
+
`NPM_TOKEN` repo secret.
|
|
139
|
+
|
|
140
|
+
Because all four studios pin this package, a breaking change here should be a
|
|
141
|
+
minor/major bump and a deliberate update in each consumer — never a silent
|
|
142
|
+
in-place edit of a re-export inside an app repo.
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@legenki/studio-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shared auth, paywall and utilities for the Legenki browser studios (Grafema, Ritmo, Divix, Lumen).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"author": "Andy Legenki",
|
|
8
|
+
"homepage": "https://github.com/legenki/studio-core#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/legenki/studio-core.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/legenki/studio-core/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"supabase",
|
|
18
|
+
"stripe",
|
|
19
|
+
"paywall",
|
|
20
|
+
"subscription",
|
|
21
|
+
"auth"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"!src/__tests__"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
"./auth": "./src/auth.js",
|
|
30
|
+
"./paywall": "./src/paywall.js",
|
|
31
|
+
"./deepMerge": "./src/deepMerge.js",
|
|
32
|
+
"./datetime": "./src/datetime.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@supabase/supabase-js": "^2.108.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@supabase/supabase-js": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"lint": "eslint src --max-warnings 0",
|
|
49
|
+
"prepublishOnly": "npm run lint && npm test"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^10.0.1",
|
|
53
|
+
"@supabase/supabase-js": "^2.108.2",
|
|
54
|
+
"eslint": "^10.4.1",
|
|
55
|
+
"globals": "^17.6.0",
|
|
56
|
+
"jsdom": "^29.1.1",
|
|
57
|
+
"vitest": "^4.1.8"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
|
4
|
+
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
|
5
|
+
|
|
6
|
+
// Null when env is not configured (build without .env / CI secrets) —
|
|
7
|
+
// the app then runs in signed-out free mode instead of crashing at load.
|
|
8
|
+
export const supabase = SUPABASE_URL && SUPABASE_ANON_KEY
|
|
9
|
+
? createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
|
10
|
+
: null;
|
|
11
|
+
|
|
12
|
+
if (!supabase) {
|
|
13
|
+
console.warn('[auth] Supabase env vars missing — auth disabled, running in free mode.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// In-memory subscription cache — populated on auth state change
|
|
17
|
+
let _subscription = null;
|
|
18
|
+
|
|
19
|
+
// Test escape hatch — only used in auth.test.js
|
|
20
|
+
export function _setSubscriptionForTest(sub) {
|
|
21
|
+
_subscription = sub;
|
|
22
|
+
notifyProChange();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Returns true if the current user has an active Pro subscription. */
|
|
26
|
+
export function checkPro() {
|
|
27
|
+
if (import.meta.env.VITE_DEV_PRO === 'true') return true;
|
|
28
|
+
return _subscription?.status === 'active';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Workspaces build their control panels before auth resolves, so `isPro` is
|
|
32
|
+
// captured as false at construction. They subscribe here to rebuild once the
|
|
33
|
+
// real answer arrives — otherwise a subscriber sees the free panel until they
|
|
34
|
+
// reload. Kept separate from initAuth's single onStateChange, which the app
|
|
35
|
+
// shell owns for the header UI.
|
|
36
|
+
const _proListeners = new Set();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Registers a callback fired whenever Pro state may have changed.
|
|
40
|
+
* @param {(isPro: boolean) => void} fn
|
|
41
|
+
* @returns {() => void} unsubscribe
|
|
42
|
+
*/
|
|
43
|
+
export function onProChange(fn) {
|
|
44
|
+
_proListeners.add(fn);
|
|
45
|
+
return () => _proListeners.delete(fn);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function notifyProChange() {
|
|
49
|
+
const pro = checkPro();
|
|
50
|
+
for (const fn of _proListeners) {
|
|
51
|
+
try {
|
|
52
|
+
fn(pro);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.warn('[auth] pro listener failed:', e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Fetches and caches the subscription row for the given user. */
|
|
60
|
+
async function loadSubscription(userId) {
|
|
61
|
+
const { data, error } = await supabase
|
|
62
|
+
.from('subscriptions')
|
|
63
|
+
.select('status, plan, current_period_end')
|
|
64
|
+
.eq('user_id', userId)
|
|
65
|
+
.single();
|
|
66
|
+
_subscription = error ? null : data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialise auth. Must be called once on page load.
|
|
71
|
+
* - Restores existing session automatically (returning users get Pro state on load)
|
|
72
|
+
* - Listens for sign-in / sign-out and reloads subscription accordingly
|
|
73
|
+
* - Returns the unsubscribe function
|
|
74
|
+
*/
|
|
75
|
+
export function initAuth({ onStateChange } = {}) {
|
|
76
|
+
if (!supabase) {
|
|
77
|
+
onStateChange?.(null, null, false);
|
|
78
|
+
return () => {};
|
|
79
|
+
}
|
|
80
|
+
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
|
81
|
+
if (session?.user) {
|
|
82
|
+
await loadSubscription(session.user.id);
|
|
83
|
+
} else {
|
|
84
|
+
_subscription = null;
|
|
85
|
+
}
|
|
86
|
+
notifyProChange();
|
|
87
|
+
onStateChange?.(event, session, checkPro());
|
|
88
|
+
});
|
|
89
|
+
return () => subscription.unsubscribe();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function signIn() {
|
|
93
|
+
if (!supabase) return Promise.resolve(null);
|
|
94
|
+
return supabase.auth.signInWithOAuth({
|
|
95
|
+
provider: 'google',
|
|
96
|
+
options: { redirectTo: window.location.href },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function signOut() {
|
|
101
|
+
_subscription = null;
|
|
102
|
+
if (!supabase) return Promise.resolve(null);
|
|
103
|
+
return supabase.auth.signOut();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function getSession() {
|
|
107
|
+
if (!supabase) return null;
|
|
108
|
+
const { data: { session } } = await supabase.auth.getSession();
|
|
109
|
+
return session;
|
|
110
|
+
}
|
package/src/datetime.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure date/time helpers shared across workspaces (no p5 dependency).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filename-safe local timestamp: `YYYY-MM-DD_HH-MM-SS`.
|
|
7
|
+
* Used to suffix exported file names.
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function timestamp() {
|
|
11
|
+
const d = new Date();
|
|
12
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
13
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
|
14
|
+
}
|
package/src/deepMerge.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively merges `src` into `target`.
|
|
3
|
+
*
|
|
4
|
+
* Presets reach this function straight from a user-supplied .json file
|
|
5
|
+
* (see presetIO.openPresetFile), so the source object is untrusted: a preset
|
|
6
|
+
* containing `{"__proto__": {...}}` would otherwise walk into Object.prototype
|
|
7
|
+
* and pollute every object in the page. Keys that address the prototype chain
|
|
8
|
+
* are skipped outright — no legitimate preset needs them.
|
|
9
|
+
*/
|
|
10
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
11
|
+
|
|
12
|
+
export function deepMerge(target, src) {
|
|
13
|
+
if (!src || typeof src !== 'object') return;
|
|
14
|
+
for (const key of Object.keys(src)) {
|
|
15
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
16
|
+
const v = src[key];
|
|
17
|
+
if (v === undefined) continue;
|
|
18
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
19
|
+
if (!target[key] || typeof target[key] !== 'object') target[key] = {};
|
|
20
|
+
deepMerge(target[key], v);
|
|
21
|
+
} else if (Array.isArray(v)) {
|
|
22
|
+
target[key] = JSON.parse(JSON.stringify(v));
|
|
23
|
+
} else {
|
|
24
|
+
target[key] = v;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/paywall.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription paywall modal.
|
|
3
|
+
*
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* The plan prices shown here must match the app's landing page on legenki.com
|
|
9
|
+
* (src/content/apps/<app>.md) — they are the same offer described twice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { signIn, getSession } from './auth.js';
|
|
13
|
+
|
|
14
|
+
const STRIPE_MONTHLY_URL = import.meta.env.VITE_STRIPE_MONTHLY_URL;
|
|
15
|
+
const STRIPE_YEARLY_URL = import.meta.env.VITE_STRIPE_YEARLY_URL;
|
|
16
|
+
|
|
17
|
+
/** @type {HTMLElement|null} */
|
|
18
|
+
let _modalEl = null;
|
|
19
|
+
let _selectedPlan = 'yearly';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} PaywallConfig
|
|
23
|
+
* @property {string} appName e.g. "Grafema" — titles the modal.
|
|
24
|
+
* @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".
|
|
27
|
+
* @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%".
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @type {Required<PaywallConfig>} */
|
|
33
|
+
const config = {
|
|
34
|
+
appName: 'Pro',
|
|
35
|
+
subtitle: '',
|
|
36
|
+
features: [],
|
|
37
|
+
monthly: '$5',
|
|
38
|
+
yearly: '$40',
|
|
39
|
+
yearlyPerMonth: '$3.33 / month',
|
|
40
|
+
yearlyNote: 'Yearly · save 33%',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sets the app-specific copy. Call once at startup, before openPaywall().
|
|
45
|
+
* Re-configuring discards any modal already built so the new copy takes effect.
|
|
46
|
+
* @param {PaywallConfig} options
|
|
47
|
+
*/
|
|
48
|
+
export function configurePaywall(options = {}) {
|
|
49
|
+
Object.assign(config, options);
|
|
50
|
+
if (_modalEl) {
|
|
51
|
+
_modalEl.remove();
|
|
52
|
+
_modalEl = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeHtml(value) {
|
|
57
|
+
return String(value).replace(
|
|
58
|
+
/[&<>"']/g,
|
|
59
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ctaLabel() {
|
|
64
|
+
return _selectedPlan === 'yearly'
|
|
65
|
+
? `Subscribe — ${config.yearly} / year →`
|
|
66
|
+
: `Subscribe — ${config.monthly} / month →`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getModal() {
|
|
70
|
+
if (_modalEl) return _modalEl;
|
|
71
|
+
|
|
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>
|
|
98
|
+
</div>
|
|
99
|
+
</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>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
document.body.appendChild(_modalEl);
|
|
109
|
+
|
|
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
|
+
});
|
|
116
|
+
|
|
117
|
+
_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
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
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.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
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;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return _modalEl;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function openPaywall() {
|
|
144
|
+
const modal = getModal();
|
|
145
|
+
modal.style.display = 'flex';
|
|
146
|
+
document.body.style.overflow = 'hidden';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function closePaywall() {
|
|
150
|
+
if (!_modalEl) return;
|
|
151
|
+
_modalEl.style.display = 'none';
|
|
152
|
+
document.body.style.overflow = '';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* If the URL contains ?checkout=success, refresh auth state and show a toast.
|
|
157
|
+
* Call once on page load after initAuth().
|
|
158
|
+
* @param {() => Promise<unknown>} refreshFn
|
|
159
|
+
*/
|
|
160
|
+
export function handleCheckoutSuccess(refreshFn) {
|
|
161
|
+
const url = new URL(window.location.href);
|
|
162
|
+
if (url.searchParams.get('checkout') !== 'success') return;
|
|
163
|
+
url.searchParams.delete('checkout');
|
|
164
|
+
window.history.replaceState({}, '', url.toString());
|
|
165
|
+
refreshFn().then(() => showToast('Welcome to Pro ✦'));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function showToast(message) {
|
|
169
|
+
const toast = document.createElement('div');
|
|
170
|
+
toast.className = 'paywall-toast';
|
|
171
|
+
toast.textContent = message;
|
|
172
|
+
document.body.appendChild(toast);
|
|
173
|
+
setTimeout(() => toast.classList.add('visible'), 10);
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
toast.classList.remove('visible');
|
|
176
|
+
setTimeout(() => toast.remove(), 300);
|
|
177
|
+
}, 3000);
|
|
178
|
+
}
|