@contentcredits/sdk 2.0.3 → 2.1.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.
@@ -8,10 +8,21 @@ export declare function scrubTokenFromUrl(): void;
8
8
  * Read and store a token that may have been placed in the current URL
9
9
  * (e.g. after a mobile redirect back from the accounts site).
10
10
  * Always scrubs the token from the URL after reading it.
11
+ *
12
+ * If we're running inside a popup window (window.opener exists), we notify
13
+ * the opener via postMessage and close ourselves — this prevents the popup
14
+ * from showing the full article page after a successful login redirect.
11
15
  */
12
16
  export declare function consumeTokenFromUrl(): string | null;
13
17
  /**
14
- * Open a centered auth popup and poll for the token callback.
18
+ * Open a centered auth popup and wait for the token callback.
19
+ *
20
+ * Primary path: listens for a postMessage from the popup page (sent by
21
+ * consumeTokenFromUrl when the accounts redirect lands on our origin).
22
+ *
23
+ * Fallback path: polls popup.location.href every 200 ms for cases where
24
+ * postMessage isn't available (e.g. some mobile browsers, extensions).
25
+ *
15
26
  * Returns a promise that resolves with the token when login completes,
16
27
  * or null if the popup is closed without completing login.
17
28
  */
@@ -520,6 +520,20 @@ function createGate(options) {
520
520
  }
521
521
  contentEl.setAttribute(GATE_ATTR, 'true');
522
522
  gated = true;
523
+ // Add a gradient fade at the bottom of the visible teaser so the cutoff
524
+ // looks intentional rather than abrupt.
525
+ if (!contentEl.querySelector('[data-cc-fade]')) {
526
+ const prevPos = contentEl.style.position;
527
+ if (!prevPos || prevPos === 'static')
528
+ contentEl.style.position = 'relative';
529
+ const fadeEl = document.createElement('div');
530
+ fadeEl.setAttribute('data-cc-fade', 'true');
531
+ fadeEl.style.cssText =
532
+ 'position:absolute;bottom:0;left:0;width:100%;height:160px;' +
533
+ 'background:linear-gradient(to bottom,transparent 0%,var(--cc-bg,#fff) 100%);' +
534
+ 'pointer-events:none;z-index:1;';
535
+ contentEl.appendChild(fadeEl);
536
+ }
523
537
  return true;
524
538
  }
525
539
  function reveal() {
@@ -531,6 +545,10 @@ function createGate(options) {
531
545
  n.removeAttribute('data-cc-hidden');
532
546
  }
533
547
  });
548
+ // Remove gradient fade
549
+ const fadeEl = contentEl === null || contentEl === void 0 ? void 0 : contentEl.querySelector('[data-cc-fade]');
550
+ if (fadeEl)
551
+ fadeEl.remove();
534
552
  hiddenNodes = [];
535
553
  contentEl === null || contentEl === void 0 ? void 0 : contentEl.removeAttribute(GATE_ATTR);
536
554
  gated = false;
@@ -563,6 +581,24 @@ function createShadowHost(id) {
563
581
  host._ccShadow = root;
564
582
  return { host, root };
565
583
  }
584
+ /**
585
+ * Creates a shadow host inserted immediately after `anchorEl` in the DOM.
586
+ * Used for the inline paywall panel so it flows naturally below the content.
587
+ */
588
+ function createInlineShadowHost(id, anchorEl) {
589
+ let host = document.getElementById(id);
590
+ if (!host) {
591
+ host = document.createElement('div');
592
+ host.id = id;
593
+ anchorEl.parentNode.insertBefore(host, anchorEl.nextSibling);
594
+ }
595
+ const existing = host._ccShadow;
596
+ if (existing)
597
+ return { host, root: existing };
598
+ const root = host.attachShadow({ mode: 'open' });
599
+ host._ccShadow = root;
600
+ return { host, root };
601
+ }
566
602
  function removeShadowHost(id) {
567
603
  const host = document.getElementById(id);
568
604
  if (host)
@@ -585,56 +621,27 @@ function getPaywallStyles(primaryColor, fontFamily) {
585
621
  return `
586
622
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
587
623
 
588
- .cc-paywall-gate {
589
- position: relative;
624
+ /* Inline paywall panel — sits below the teaser content in the page flow */
625
+ .cc-paywall-inline {
590
626
  width: 100%;
591
- }
592
-
593
- .cc-teaser-fade {
594
- position: relative;
595
- }
596
- .cc-teaser-fade::after {
597
- content: '';
598
- position: absolute;
599
- bottom: 0; left: 0; width: 100%;
600
- height: 120px;
601
- background: linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%);
602
- pointer-events: none;
603
- }
604
-
605
- /* Full-viewport backdrop — centers the overlay and blocks background clicks */
606
- .cc-paywall-backdrop {
607
- position: fixed;
608
- inset: 0;
609
- background: rgba(0, 0, 0, 0.45);
610
- display: flex;
611
- align-items: center;
612
- justify-content: center;
613
- padding: 20px;
614
- pointer-events: all;
615
- }
616
-
617
- .cc-paywall-overlay {
627
+ padding: 36px 24px 32px;
618
628
  background: #fff;
619
629
  border: 1px solid #e5e7eb;
620
- border-radius: 12px;
621
- padding: 32px 24px;
622
- width: 100%;
623
- max-width: 480px;
630
+ border-top: 3px solid ${primaryColor};
631
+ border-radius: 0 0 12px 12px;
624
632
  text-align: center;
625
633
  font-family: ${fontFamily};
626
- box-shadow: 0 8px 40px rgba(0,0,0,0.18);
627
- pointer-events: all;
634
+ box-sizing: border-box;
628
635
  }
629
636
 
630
- .cc-paywall-overlay h2 {
637
+ .cc-paywall-inline h2 {
631
638
  font-size: 20px;
632
639
  font-weight: 700;
633
640
  color: #111827;
634
641
  margin-bottom: 8px;
635
642
  }
636
643
 
637
- .cc-paywall-overlay p {
644
+ .cc-paywall-inline p {
638
645
  font-size: 14px;
639
646
  color: #6b7280;
640
647
  margin-bottom: 24px;
@@ -1167,20 +1174,24 @@ function createPaywallRenderer(config) {
1167
1174
  let root = null;
1168
1175
  let overlay = null;
1169
1176
  function init() {
1170
- const { root: shadowRoot } = createShadowHost(HOST_ID);
1177
+ // Insert the shadow host immediately after the content element so the
1178
+ // paywall panel flows inline below the teaser — no modal, no backdrop.
1179
+ const contentEl = document.querySelector(config.contentSelector);
1180
+ if (!contentEl)
1181
+ return;
1182
+ const { root: shadowRoot } = createInlineShadowHost(HOST_ID, contentEl);
1171
1183
  root = shadowRoot;
1172
1184
  injectStyles(root, getPaywallStyles(config.theme.primaryColor, config.theme.fontFamily));
1173
- // Backdrop: fixed full-viewport flex container — centers the overlay and
1174
- // blocks interaction with the page behind it (pointer-events: all in CSS).
1175
- const backdrop = el('div');
1176
- backdrop.className = 'cc-paywall-backdrop';
1177
- root.appendChild(backdrop);
1178
1185
  overlay = el('div');
1179
- overlay.className = 'cc-paywall-overlay';
1180
- backdrop.appendChild(overlay);
1186
+ overlay.className = 'cc-paywall-inline';
1187
+ root.appendChild(overlay);
1181
1188
  }
1182
1189
  function render(state, callbacks, meta) {
1183
1190
  var _a, _b, _c;
1191
+ // During the initial access check the content is already hidden by the
1192
+ // gate — no need to show a spinner in the paywall slot.
1193
+ if (state === 'checking')
1194
+ return;
1184
1195
  if (!overlay)
1185
1196
  init();
1186
1197
  if (!overlay)
@@ -1189,9 +1200,6 @@ function createPaywallRenderer(config) {
1189
1200
  while (overlay.firstChild)
1190
1201
  overlay.removeChild(overlay.firstChild);
1191
1202
  switch (state) {
1192
- case 'checking':
1193
- renderChecking(overlay);
1194
- break;
1195
1203
  case 'login':
1196
1204
  renderLogin(overlay, callbacks);
1197
1205
  break;
@@ -1209,20 +1217,6 @@ function createPaywallRenderer(config) {
1209
1217
  break;
1210
1218
  }
1211
1219
  }
1212
- function renderChecking(parent) {
1213
- const spinner = el('div');
1214
- spinner.className = 'cc-spinner';
1215
- spinner.style.margin = '0 auto 12px';
1216
- spinner.style.width = '24px';
1217
- spinner.style.height = '24px';
1218
- spinner.style.borderWidth = '2px';
1219
- spinner.style.borderColor = '#e5e7eb';
1220
- spinner.style.borderTopColor = config.theme.primaryColor;
1221
- const text = el('p', 'Checking access...');
1222
- text.style.cssText = 'font-size:14px;color:#6b7280;text-align:center;font-family:' + config.theme.fontFamily;
1223
- parent.appendChild(spinner);
1224
- parent.appendChild(text);
1225
- }
1226
1220
  function renderLogin(parent, cb) {
1227
1221
  parent.appendChild(el('h2', 'This article requires a subscription'));
1228
1222
  parent.appendChild(el('p', 'Log in with your Content Credits account to unlock this article.'));
@@ -1467,6 +1461,10 @@ function scrubTokenFromUrl() {
1467
1461
  * Read and store a token that may have been placed in the current URL
1468
1462
  * (e.g. after a mobile redirect back from the accounts site).
1469
1463
  * Always scrubs the token from the URL after reading it.
1464
+ *
1465
+ * If we're running inside a popup window (window.opener exists), we notify
1466
+ * the opener via postMessage and close ourselves — this prevents the popup
1467
+ * from showing the full article page after a successful login redirect.
1470
1468
  */
1471
1469
  function consumeTokenFromUrl() {
1472
1470
  var _a, _b;
@@ -1481,33 +1479,90 @@ function consumeTokenFromUrl() {
1481
1479
  }
1482
1480
  if (token) {
1483
1481
  tokenStorage.set(token);
1482
+ // If we're inside a popup (opened by openAuthPopup), notify the opener
1483
+ // and close instead of rendering the page. This fixes the bug where the
1484
+ // popup shows the blog article after the accounts redirect.
1485
+ if (window.opener && !window.opener.closed) {
1486
+ try {
1487
+ window.opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1488
+ }
1489
+ catch (_c) {
1490
+ // opener is cross-origin or restricted — fall through, URL polling will handle it
1491
+ }
1492
+ // Brief delay so the opener can process the message before the popup closes
1493
+ setTimeout(() => { try {
1494
+ window.close();
1495
+ }
1496
+ catch ( /* ignore */_a) { /* ignore */ } }, 300);
1497
+ }
1484
1498
  return token;
1485
1499
  }
1486
1500
  }
1487
- catch (_c) {
1501
+ catch (_d) {
1488
1502
  // ignore
1489
1503
  }
1490
1504
  return null;
1491
1505
  }
1492
1506
  /**
1493
- * Open a centered auth popup and poll for the token callback.
1507
+ * Open a centered auth popup and wait for the token callback.
1508
+ *
1509
+ * Primary path: listens for a postMessage from the popup page (sent by
1510
+ * consumeTokenFromUrl when the accounts redirect lands on our origin).
1511
+ *
1512
+ * Fallback path: polls popup.location.href every 200 ms for cases where
1513
+ * postMessage isn't available (e.g. some mobile browsers, extensions).
1514
+ *
1494
1515
  * Returns a promise that resolves with the token when login completes,
1495
1516
  * or null if the popup is closed without completing login.
1496
1517
  */
1497
1518
  function openAuthPopup(authUrl) {
1498
1519
  return new Promise(resolve => {
1499
1520
  let popup = null;
1521
+ let settled = false;
1522
+ function finish(token) {
1523
+ if (settled)
1524
+ return;
1525
+ settled = true;
1526
+ clearInterval(timer);
1527
+ window.removeEventListener('message', onMessage);
1528
+ resolve(token);
1529
+ }
1530
+ // ── Primary: postMessage from popup ───────────────────────────────────
1531
+ function onMessage(event) {
1532
+ var _a;
1533
+ // Only accept messages from our own origin
1534
+ if (event.origin !== window.location.origin)
1535
+ return;
1536
+ if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) !== 'cc_auth_callback')
1537
+ return;
1538
+ const token = event.data.token;
1539
+ const refreshToken = event.data.refreshToken;
1540
+ if (!token)
1541
+ return;
1542
+ tokenStorage.set(token);
1543
+ if (refreshToken)
1544
+ refreshTokenStorage.set(refreshToken);
1545
+ try {
1546
+ popup === null || popup === void 0 ? void 0 : popup.close();
1547
+ }
1548
+ catch ( /* ignore */_b) { /* ignore */ }
1549
+ finish(token);
1550
+ }
1551
+ window.addEventListener('message', onMessage);
1500
1552
  try {
1501
1553
  popup = window.open(authUrl, POPUP_NAME, centeredSpecs());
1502
1554
  }
1503
1555
  catch (_a) {
1504
- // popup blocked — fall through to null
1556
+ window.removeEventListener('message', onMessage);
1557
+ resolve(null);
1558
+ return;
1505
1559
  }
1506
- // Popup blocked
1507
1560
  if (!popup || popup.closed) {
1561
+ window.removeEventListener('message', onMessage);
1508
1562
  resolve(null);
1509
1563
  return;
1510
1564
  }
1565
+ // ── Fallback: URL polling ─────────────────────────────────────────────
1511
1566
  const POLL_MS = 200;
1512
1567
  const MAX_WAIT_MS = 5 * 60 * 1000; // 5 minutes
1513
1568
  let elapsed = 0;
@@ -1515,17 +1570,15 @@ function openAuthPopup(authUrl) {
1515
1570
  var _a, _b;
1516
1571
  elapsed += POLL_MS;
1517
1572
  if (!popup || popup.closed) {
1518
- clearInterval(timer);
1519
- resolve(tokenStorage.get()); // may have been set just before close
1573
+ finish(tokenStorage.get());
1520
1574
  return;
1521
1575
  }
1522
1576
  if (elapsed > MAX_WAIT_MS) {
1523
- clearInterval(timer);
1524
1577
  try {
1525
1578
  popup.close();
1526
1579
  }
1527
1580
  catch ( /* ignore */_c) { /* ignore */ }
1528
- resolve(null);
1581
+ finish(null);
1529
1582
  return;
1530
1583
  }
1531
1584
  try {
@@ -1537,15 +1590,13 @@ function openAuthPopup(authUrl) {
1537
1590
  const refreshToken = (_b = params.get('refresh_token')) !== null && _b !== void 0 ? _b : params.get('cc_refresh_token');
1538
1591
  if (token) {
1539
1592
  tokenStorage.set(token);
1540
- if (refreshToken) {
1593
+ if (refreshToken)
1541
1594
  refreshTokenStorage.set(refreshToken);
1542
- }
1543
- clearInterval(timer);
1544
1595
  try {
1545
1596
  popup.close();
1546
1597
  }
1547
1598
  catch ( /* ignore */_d) { /* ignore */ }
1548
- resolve(token);
1599
+ finish(token);
1549
1600
  }
1550
1601
  }
1551
1602
  }
@@ -1556,8 +1607,10 @@ function openAuthPopup(authUrl) {
1556
1607
  });
1557
1608
  }
1558
1609
 
1559
- function createPaywall(config, creditsApi, state, emitter) {
1560
- const gate = createGate({
1610
+ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1611
+ // Accept a pre-created gate so the caller can call gate.hide() synchronously
1612
+ // before any async work, preventing a flash of the full article content.
1613
+ const gate = existingGate !== null && existingGate !== void 0 ? existingGate : createGate({
1561
1614
  selector: config.contentSelector,
1562
1615
  teaserParagraphs: config.teaserParagraphs,
1563
1616
  });
@@ -2622,14 +2675,23 @@ class ContentCredits {
2622
2675
  async _start() {
2623
2676
  // 1. Consume any token that arrived in the URL (mobile redirect flow)
2624
2677
  consumeTokenFromUrl();
2625
- // 2. If no access token in memory/session, attempt a silent refresh.
2678
+ // 2. Hide premium content immediately (synchronous) before any async work.
2679
+ // This prevents the flash of full article content that would otherwise
2680
+ // appear during the token-refresh and access-check network round-trips.
2681
+ const earlyGate = createGate({
2682
+ selector: this.config.contentSelector,
2683
+ teaserParagraphs: this.config.teaserParagraphs,
2684
+ });
2685
+ earlyGate.hide();
2686
+ // 3. If no access token in memory/session, attempt a silent refresh.
2626
2687
  // This runs on every new browser session (after the browser was closed)
2627
- // and silently re-authenticates the user using their stored refresh token
2628
- // — no popup, no visible delay, no paywall flash.
2688
+ // and silently re-authenticates the user using their stored refresh token.
2629
2689
  if (!tokenStorage.has()) {
2630
2690
  await tryRefreshSession(this.config.apiBaseUrl);
2631
2691
  }
2632
- this.paywallModule = createPaywall(this.config, this.creditsApi, this.state, this.emitter);
2692
+ // Pass the pre-created gate so createPaywall reuses the same instance
2693
+ // (and its hiddenNodes list) rather than creating a second one.
2694
+ this.paywallModule = createPaywall(this.config, this.creditsApi, this.state, this.emitter, earlyGate);
2633
2695
  if (this.config.enableComments) {
2634
2696
  this.commentsModule = createComments(this.config, this.commentsApi, this.emitter);
2635
2697
  this.commentsModule.init();
@@ -2679,7 +2741,7 @@ class ContentCredits {
2679
2741
  }
2680
2742
  /** SDK version string. */
2681
2743
  static get version() {
2682
- return "2.0.3";
2744
+ return "2.1.0";
2683
2745
  }
2684
2746
  }
2685
2747
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────