@contentcredits/sdk 2.12.0 → 2.14.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 CHANGED
File without changes
package/README.md CHANGED
@@ -13,9 +13,10 @@ Drop-in paywall and threaded comment system for any website. Add credit-based ar
13
13
  ## What it does
14
14
 
15
15
  - **Paywall** — hides premium content behind a credit gate using a CSS selector. Reveals it instantly when the reader pays. No server-side content splitting needed.
16
+ - **Customisable top slot** — inject your own content (React widget, donation banner, structured items) above the SDK's unlock button.
16
17
  - **Comments** — threaded comment panel with likes, edit, delete, and sorting. Rendered in a Shadow DOM so it never conflicts with your CSS.
17
18
  - **Auth** — popup-based login on desktop, redirect flow on mobile. Tokens stored in memory (never cookies).
18
- - **Extension support** — detects the Content Credits Chrome extension for a one-click experience.
19
+ - **Extension support** — detects the Content Credits Chrome extension for a one-click experience, with automatic fallback if the extension service worker is unresponsive.
19
20
 
20
21
  ---
21
22
 
@@ -28,7 +29,7 @@ npm install @contentcredits/sdk
28
29
  Or via CDN (no build step):
29
30
 
30
31
  ```html
31
- <script src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.0.0/dist/content-credits.umd.min.js"></script>
32
+ <script src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.12.0/dist/content-credits.umd.min.js"></script>
32
33
  ```
33
34
 
34
35
  ---
@@ -44,7 +45,7 @@ Or via CDN (no build step):
44
45
  </div>
45
46
 
46
47
  <!-- Load and initialise the SDK -->
47
- <script src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.0.0/dist/content-credits.umd.min.js"></script>
48
+ <script src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.12.0/dist/content-credits.umd.min.js"></script>
48
49
  <script>
49
50
  ContentCreditsSDK.ContentCredits.init({
50
51
  apiKey: 'pub_YOUR_API_KEY',
@@ -59,7 +60,7 @@ Or via CDN (no build step):
59
60
 
60
61
  ```html
61
62
  <script
62
- src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.0.0/dist/content-credits.umd.min.js"
63
+ src="https://cdn.jsdelivr.net/npm/@contentcredits/sdk@2.12.0/dist/content-credits.umd.min.js"
63
64
  data-api-key="pub_YOUR_API_KEY"
64
65
  data-content-selector="#premium-content"
65
66
  data-teaser-paragraphs="2"
@@ -94,13 +95,90 @@ cc.on('paywall:hidden', () => {
94
95
  | `teaserParagraphs` | `number` | `2` | Paragraphs to show before the paywall |
95
96
  | `enableComments` | `boolean` | `true` | Show the comment widget |
96
97
  | `articleUrl` | `string` | `location.href` | Canonical URL of the article |
98
+ | `paywallMode` | `'overlay' \| 'inline'` | `'overlay'` | Paywall layout — overlay sits directly below the teaser; inline is the legacy flow-based panel |
99
+ | `paywallTopSlot` | see below | — | Content rendered above the SDK's unlock button — accepts a React element, structured items, `HTMLElement`, or factory function |
100
+ | `reactDOM` | `ReactDOMAdapter` | — | Your `ReactDOM` instance — required when `paywallTopSlot` is a React element |
97
101
  | `theme.primaryColor` | `string` | `'#44C678'` | Brand colour for buttons |
98
102
  | `theme.fontFamily` | `string` | system UI | Font for all SDK UI |
103
+ | `headless` | `boolean` | `false` | Disable all built-in DOM/UI — manage everything yourself via state and callbacks |
99
104
  | `onAccessGranted` | `() => void` | — | Fires when content is unlocked |
100
105
  | `debug` | `boolean` | `false` | Verbose console logging |
101
106
 
102
107
  ---
103
108
 
109
+ ## Paywall top slot
110
+
111
+ The `paywallTopSlot` lets you inject custom content — a donation widget, promo banner, or anything else — above the SDK's own unlock button. The slot sits inside the SDK's Shadow DOM so your styles are isolated from the host page.
112
+
113
+ ### React widget (recommended for React apps)
114
+
115
+ Pass your ReactDOM instance alongside the JSX element. Works with React 18 (`createRoot`) and React 16/17 (`render`).
116
+
117
+ ```tsx
118
+ import ReactDOM from 'react-dom/client'; // React 18
119
+ import { ContentCredits } from '@contentcredits/sdk';
120
+ import { DonationWidget } from './DonationWidget';
121
+
122
+ ContentCredits.init({
123
+ apiKey: 'pub_YOUR_API_KEY',
124
+ reactDOM,
125
+ paywallTopSlot: <DonationWidget />,
126
+ });
127
+ ```
128
+
129
+ ### Structured items
130
+
131
+ The SDK renders and styles these consistently inside its Shadow DOM:
132
+
133
+ ```ts
134
+ ContentCredits.init({
135
+ apiKey: 'pub_YOUR_API_KEY',
136
+ paywallTopSlot: [
137
+ { type: 'heading', content: 'Donate to access this story.' },
138
+ { type: 'text', content: 'Donate now for unlimited stories. Cancel anytime.' },
139
+ { type: 'button', content: 'See Donation Options', variant: 'primary', onClick: () => openDonateFlow() },
140
+ ],
141
+ });
142
+ ```
143
+
144
+ Available item types:
145
+
146
+ | `type` | Description | Extra props |
147
+ |--------|-------------|-------------|
148
+ | `heading` | Large bold heading | `content` |
149
+ | `subheading` | Medium bold text | `content` |
150
+ | `text` | Body copy | `content` |
151
+ | `button` | Styled button | `content`, `variant` (`primary` \| `secondary` \| `outline`), `onClick` |
152
+ | `divider` | Horizontal rule with optional label | `content` |
153
+
154
+ ### HTMLElement
155
+
156
+ ```ts
157
+ const banner = document.createElement('div');
158
+ banner.innerHTML = '<strong>Support independent journalism</strong>';
159
+
160
+ ContentCredits.init({
161
+ apiKey: 'pub_YOUR_API_KEY',
162
+ paywallTopSlot: banner,
163
+ });
164
+ ```
165
+
166
+ ### Factory function
167
+
168
+ Full control — receives the slot container element and mounts whatever you need:
169
+
170
+ ```ts
171
+ ContentCredits.init({
172
+ apiKey: 'pub_YOUR_API_KEY',
173
+ paywallTopSlot: (container) => {
174
+ // vanilla JS, Vue, Svelte — anything goes
175
+ container.innerHTML = `<p>Support us to keep reading.</p>`;
176
+ },
177
+ });
178
+ ```
179
+
180
+ ---
181
+
104
182
  ## Events
105
183
 
106
184
  ```ts
@@ -128,39 +206,91 @@ const cc = ContentCredits.init(config);
128
206
 
129
207
  cc.on(event, handler) // subscribe — returns unsubscribe fn
130
208
  cc.off(event, handler) // unsubscribe
209
+ cc.subscribe(fn) // reactive state changes — returns unsubscribe fn
131
210
  cc.getState() // → SDKState snapshot
132
211
  cc.checkAccess() // trigger access check manually
212
+ cc.login() // open login flow programmatically
213
+ cc.purchase() // trigger article purchase
214
+ cc.buyMoreCredits() // open dashboard to top up balance
133
215
  cc.openComments() // open comment panel
134
216
  cc.closeComments() // close comment panel
135
217
  cc.isLoggedIn() // → boolean
218
+ cc.getToken() // → access token string | null
219
+ cc.logout() // revoke session and clear local auth state
136
220
  cc.destroy() // tear down SDK, restore hidden content
137
221
 
138
- ContentCredits.version // → "2.0.0"
222
+ ContentCredits.version // → "2.12.0"
139
223
  ```
140
224
 
141
225
  ---
142
226
 
143
227
  ## React / Next.js
144
228
 
229
+ ### Default UI with a React top slot
230
+
231
+ The simplest integration — the SDK handles all paywall rendering; you only supply the top section:
232
+
145
233
  ```tsx
146
- 'use client'; // Next.js App Router
234
+ 'use client';
147
235
 
148
236
  import { useEffect } from 'react';
237
+ import ReactDOM from 'react-dom/client';
149
238
  import { ContentCredits } from '@contentcredits/sdk';
239
+ import { DonationWidget } from './DonationWidget';
150
240
 
151
- export function PremiumGate({ apiKey, children }) {
241
+ export function PremiumGate({ apiKey }: { apiKey: string }) {
152
242
  useEffect(() => {
153
243
  const cc = ContentCredits.init({
154
244
  apiKey,
155
245
  contentSelector: '#premium-content',
246
+ reactDOM,
247
+ paywallTopSlot: <DonationWidget />,
248
+ });
249
+ return () => cc.destroy();
250
+ }, [apiKey]);
251
+
252
+ return <div id="premium-content">{/* article content */}</div>;
253
+ }
254
+ ```
255
+
256
+ ### Headless mode (full custom UI)
257
+
258
+ Use `headless: true` when you want to build the entire paywall UI yourself in React. The SDK manages auth and access checks but renders no DOM of its own.
259
+
260
+ ```tsx
261
+ 'use client';
262
+
263
+ import { useEffect, useRef, useState } from 'react';
264
+ import { ContentCredits } from '@contentcredits/sdk';
265
+ import type { SDKState } from '@contentcredits/sdk';
266
+
267
+ export function PremiumGate({ apiKey, children }: { apiKey: string; children: React.ReactNode }) {
268
+ const ccRef = useRef<ContentCredits | null>(null);
269
+ const [state, setState] = useState<SDKState | null>(null);
270
+
271
+ useEffect(() => {
272
+ const cc = ContentCredits.init({
273
+ apiKey,
274
+ headless: true,
275
+ onStateChange: setState,
156
276
  });
277
+ ccRef.current = cc;
157
278
  return () => cc.destroy();
158
279
  }, [apiKey]);
159
280
 
160
- return <div id="premium-content">{children}</div>;
281
+ if (state?.hasAccess) return <>{children}</>;
282
+
283
+ return (
284
+ <div>
285
+ {/* your teaser content */}
286
+ <button onClick={() => ccRef.current?.purchase()}>Unlock article</button>
287
+ </div>
288
+ );
161
289
  }
162
290
  ```
163
291
 
292
+ See [`examples/nextjs-blog`](./examples/nextjs-blog) for a full working Next.js 14 (App Router) implementation.
293
+
164
294
  ---
165
295
 
166
296
  ## Requirements
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -597,7 +597,7 @@ function createShadowHost(id) {
597
597
  host = document.createElement('div');
598
598
  host.id = id;
599
599
  // The host element itself is invisible; only its shadow children show
600
- host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
600
+ host.style.cssText = 'position:fixed;bottom:0;left:0;width:100%;height:auto;z-index:2147483647;';
601
601
  document.body.appendChild(host);
602
602
  }
603
603
  const existing = host._ccShadow;
@@ -676,34 +676,41 @@ function getPaywallStyles(primaryColor, fontFamily) {
676
676
 
677
677
  /* ── Overlay paywall panel — full-width white panel below gated content ── */
678
678
  .cc-paywall-overlay {
679
+ /* Fixed to the bottom of the viewport — always visible, full width */
680
+ position: fixed;
681
+ bottom: 0;
682
+ left: 0;
679
683
  width: 100%;
680
684
  background: #fff;
685
+ box-shadow: 0 -8px 40px rgba(0,0,0,0.10);
686
+ border-top: 1px solid #e5e7eb;
681
687
  font-family: ${fontFamily};
682
688
  }
683
689
 
684
- /* Gradient that fades the article into the white panel */
690
+ /* Gradient that fades the article into the panel.
691
+ Sits above the panel via position:absolute + negative top. */
685
692
  .cc-paywall-overlay-gradient {
686
- width: 100%;
693
+ position: absolute;
694
+ top: -120px;
695
+ left: 0;
696
+ right: 0;
687
697
  height: 120px;
688
698
  background: linear-gradient(to bottom, transparent 0%, #fff 100%);
689
- margin-top: -120px;
690
699
  pointer-events: none;
691
- position: relative;
692
- z-index: 1;
693
700
  }
694
701
 
695
702
  /* Top slot — client-supplied content */
696
703
  .cc-paywall-overlay-slot {
697
- padding: 32px 24px 0;
704
+ padding: 20px 24px 0;
698
705
  display: flex;
699
706
  flex-direction: column;
700
707
  align-items: center;
701
- gap: 12px;
708
+ gap: 10px;
702
709
  }
703
710
 
704
711
  /* Our SDK's unlock section below the slot */
705
712
  .cc-paywall-overlay-body {
706
- padding: 20px 24px 32px;
713
+ padding: 16px 24px 24px;
707
714
  display: flex;
708
715
  flex-direction: column;
709
716
  align-items: center;
@@ -1285,7 +1292,12 @@ function createPaywallRenderer(config) {
1285
1292
  const contentEl = document.querySelector(config.contentSelector);
1286
1293
  if (!contentEl)
1287
1294
  return;
1288
- const { root: shadowRoot } = createInlineShadowHost(HOST_ID, contentEl);
1295
+ // Overlay mode: fixed to the bottom of the viewport — attach to body so it
1296
+ // is never constrained by the article's max-width container.
1297
+ // Inline mode: inserted after the content element in document flow.
1298
+ const { root: shadowRoot } = config.paywallMode === 'overlay'
1299
+ ? createShadowHost(HOST_ID)
1300
+ : createInlineShadowHost(HOST_ID, contentEl);
1289
1301
  root = shadowRoot;
1290
1302
  injectStyles(root, getPaywallStyles(config.theme.primaryColor, config.theme.fontFamily));
1291
1303
  if (config.paywallMode === 'overlay') {
@@ -1301,23 +1313,25 @@ function createPaywallRenderer(config) {
1301
1313
  shadowRoot.appendChild(body);
1302
1314
  }
1303
1315
  function initOverlay(shadowRoot, contentEl) {
1304
- var _a, _b;
1316
+ var _a;
1305
1317
  const panel = el('div');
1306
1318
  panel.className = 'cc-paywall-overlay';
1307
- // Gradient that visually fades the article into the white panel.
1308
- // We read the computed background of the content element's parent so the
1309
- // gradient blends correctly on sites with non-white backgrounds.
1319
+ // Gradient that visually fades the article into the panel.
1320
+ // Reads the page background so the gradient colour matches non-white sites.
1310
1321
  const gradient = el('div');
1311
1322
  gradient.className = 'cc-paywall-overlay-gradient';
1312
- const parentBg = getComputedStyle((_a = contentEl.parentElement) !== null && _a !== void 0 ? _a : contentEl).backgroundColor;
1313
- if (parentBg && parentBg !== 'rgba(0, 0, 0, 0)' && parentBg !== 'transparent') {
1314
- gradient.style.background = `linear-gradient(to bottom, transparent 0%, ${parentBg} 100%)`;
1323
+ const pageBg = getComputedStyle(document.body).backgroundColor;
1324
+ if (pageBg && pageBg !== 'rgba(0, 0, 0, 0)' && pageBg !== 'transparent') {
1325
+ gradient.style.background = `linear-gradient(to bottom, transparent 0%, ${pageBg} 100%)`;
1315
1326
  }
1316
1327
  panel.appendChild(gradient);
1328
+ // Add bottom padding to the content element so the fixed panel
1329
+ // doesn't overlap the last readable line of the teaser.
1330
+ contentEl.style.paddingBottom = '200px';
1317
1331
  // Top slot — client content
1318
1332
  const slot = el('div');
1319
1333
  slot.className = 'cc-paywall-overlay-slot';
1320
- reactRoot = (_b = mountTopSlot(slot, config.paywallTopSlot, config.reactDOM)) !== null && _b !== void 0 ? _b : null;
1334
+ reactRoot = (_a = mountTopSlot(slot, config.paywallTopSlot, config.reactDOM)) !== null && _a !== void 0 ? _a : null;
1321
1335
  panel.appendChild(slot);
1322
1336
  // Only render the "or" divider between slot and body when the slot has content
1323
1337
  if (config.paywallTopSlot) {
@@ -1447,6 +1461,12 @@ function createPaywallRenderer(config) {
1447
1461
  reactRoot === null || reactRoot === void 0 ? void 0 : reactRoot.unmount();
1448
1462
  reactRoot = null;
1449
1463
  removeShadowHost(HOST_ID);
1464
+ // Remove the padding we added to the content element
1465
+ if (config.paywallMode === 'overlay') {
1466
+ const contentEl = document.querySelector(config.contentSelector);
1467
+ if (contentEl)
1468
+ contentEl.style.paddingBottom = '';
1469
+ }
1450
1470
  root = null;
1451
1471
  body = null;
1452
1472
  }
@@ -3207,7 +3227,7 @@ class ContentCredits {
3207
3227
  }
3208
3228
  /** SDK version string. */
3209
3229
  static get version() {
3210
- return "2.3.0";
3230
+ return "2.14.0";
3211
3231
  }
3212
3232
  }
3213
3233
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────