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