@contentcredits/sdk 2.14.0 → 2.16.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.
@@ -534,14 +534,16 @@ function createGate(options) {
534
534
  }
535
535
  });
536
536
  }
537
- else {
538
- // Not enough paragraphs to split hide the entire content
537
+ else if (options.teaserParagraphs === 0) {
538
+ // Explicitly hide everything caller requested no teaser (teaserParagraphs: 0)
539
539
  hiddenNodes = Array.from(contentEl.childNodes);
540
540
  hiddenNodes.forEach(n => {
541
541
  if (n instanceof HTMLElement)
542
542
  n.style.display = 'none';
543
543
  });
544
544
  }
545
+ // else: content has at most as many paragraphs as the teaser threshold allows
546
+ // (e.g. server-side teaser splitting already trimmed the DOM) — show everything
545
547
  contentEl.setAttribute(GATE_ATTR, 'true');
546
548
  gated = true;
547
549
  // In overlay mode the gradient is part of the paywall panel itself.
@@ -645,183 +647,229 @@ function getPaywallStyles(primaryColor, fontFamily) {
645
647
  return `
646
648
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
647
649
 
648
- /* ── Inline paywall panel sits below teaser in the page flow ── */
650
+ /* ─── Inline paywall panel ──────────────────────────────────────────── */
649
651
  .cc-paywall-inline {
650
652
  width: 100%;
651
- padding: 36px 24px 32px;
653
+ padding: 32px 28px 28px;
652
654
  background: #fff;
653
- border: 1px solid #e5e7eb;
655
+ border: 1px solid #e2e8f0;
654
656
  border-top: 3px solid ${primaryColor};
655
657
  border-radius: 0 0 12px 12px;
656
658
  text-align: center;
657
659
  font-family: ${fontFamily};
658
- box-sizing: border-box;
659
660
  }
660
-
661
661
  .cc-paywall-inline h2 {
662
662
  font-size: 20px;
663
663
  font-weight: 700;
664
- color: #111827;
664
+ color: #0f172a;
665
665
  margin-bottom: 8px;
666
+ letter-spacing: -0.015em;
667
+ line-height: 1.25;
666
668
  }
667
-
668
669
  .cc-paywall-inline p {
669
670
  font-size: 14px;
670
- color: #6b7280;
671
- margin-bottom: 24px;
671
+ color: #64748b;
672
672
  line-height: 1.6;
673
+ margin-bottom: 20px;
673
674
  }
674
675
 
675
- /* ── Overlay paywall panel full-width white panel below gated content ── */
676
+ /* ─── Overlay paywall panel ─────────────────────────────────────────── */
677
+ /*
678
+ * Fixed to the bottom of the viewport, full width.
679
+ * Elevation uses layered shadows — no harsh border-top line.
680
+ * Thin hairline at the top (1px shadow) + ambient shadow for depth.
681
+ */
676
682
  .cc-paywall-overlay {
677
- /* Fixed to the bottom of the viewport — always visible, full width */
678
683
  position: fixed;
679
684
  bottom: 0;
680
685
  left: 0;
681
686
  width: 100%;
682
687
  background: #fff;
683
- box-shadow: 0 -8px 40px rgba(0,0,0,0.10);
684
- border-top: 1px solid #e5e7eb;
688
+ box-shadow:
689
+ 0 -1px 0 rgba(15, 23, 42, 0.07),
690
+ 0 -4px 12px rgba(15, 23, 42, 0.04),
691
+ 0 -24px 64px rgba(15, 23, 42, 0.07);
685
692
  font-family: ${fontFamily};
686
693
  }
687
694
 
688
- /* Gradient that fades the article into the panel.
689
- Sits above the panel via position:absolute + negative top. */
695
+ /*
696
+ * Gradient that fades the article content into the panel.
697
+ * Multi-stop with a cubic-ease-ish curve so it reads as depth,
698
+ * not as an obvious white overlay.
699
+ * initOverlay() overrides this via inline style with the real page bg colour.
700
+ */
690
701
  .cc-paywall-overlay-gradient {
691
702
  position: absolute;
692
- top: -120px;
703
+ top: -160px;
693
704
  left: 0;
694
705
  right: 0;
695
- height: 120px;
696
- background: linear-gradient(to bottom, transparent 0%, #fff 100%);
706
+ height: 160px;
697
707
  pointer-events: none;
708
+ background: linear-gradient(
709
+ to bottom,
710
+ transparent 0%,
711
+ rgba(255,255,255,0.08) 30%,
712
+ rgba(255,255,255,0.55) 60%,
713
+ rgba(255,255,255,0.90) 82%,
714
+ #ffffff 100%
715
+ );
698
716
  }
699
717
 
700
- /* Top slot — client-supplied content */
718
+ /* Top slot — publisher-supplied content */
701
719
  .cc-paywall-overlay-slot {
702
- padding: 20px 24px 0;
720
+ padding: 18px 24px 0;
703
721
  display: flex;
704
722
  flex-direction: column;
705
723
  align-items: center;
706
- gap: 10px;
724
+ gap: 5px;
707
725
  }
708
726
 
709
- /* Our SDK's unlock section below the slot */
727
+ /* SDK's own action section below the slot */
710
728
  .cc-paywall-overlay-body {
711
- padding: 16px 24px 24px;
729
+ padding: 14px 24px 26px;
712
730
  display: flex;
713
731
  flex-direction: column;
714
732
  align-items: center;
715
- gap: 0;
733
+ gap: 12px;
716
734
  text-align: center;
717
735
  }
718
736
 
719
- /* Slot item heading */
737
+ /* ─── Slot typography ───────────────────────────────────────────────── */
720
738
  .cc-slot-heading {
721
- font-size: 22px;
739
+ font-size: 19px;
722
740
  font-weight: 700;
723
- color: #111827;
741
+ color: #0f172a;
724
742
  text-align: center;
725
743
  line-height: 1.3;
744
+ letter-spacing: -0.015em;
726
745
  }
727
-
728
- /* Slot item — subheading */
729
746
  .cc-slot-subheading {
730
- font-size: 16px;
747
+ font-size: 15px;
731
748
  font-weight: 600;
732
- color: #374151;
749
+ color: #1e293b;
733
750
  text-align: center;
734
751
  }
735
-
736
- /* Slot item — body text */
737
752
  .cc-slot-text {
738
- font-size: 14px;
739
- color: #6b7280;
753
+ font-size: 13px;
754
+ color: #64748b;
740
755
  text-align: center;
741
- line-height: 1.6;
756
+ line-height: 1.55;
742
757
  }
743
758
 
744
- /* Slot item divider with optional label */
759
+ /* Visual separator between slot and body */
745
760
  .cc-slot-divider {
746
761
  display: flex;
747
762
  align-items: center;
748
- gap: 12px;
749
- width: 100%;
750
- max-width: 320px;
751
- font-size: 13px;
752
- color: #9ca3af;
763
+ gap: 10px;
764
+ font-size: 12px;
765
+ color: #94a3b8;
753
766
  }
754
767
  .cc-slot-divider::before,
755
768
  .cc-slot-divider::after {
756
769
  content: '';
757
770
  flex: 1;
758
- border-top: 1px solid #e5e7eb;
771
+ height: 1px;
772
+ background: #e2e8f0;
759
773
  }
760
774
 
775
+ /* ─── Buttons ───────────────────────────────────────────────────────── */
776
+ /*
777
+ * All primary paywall CTAs use .cc-btn-primary (filled, brand colour).
778
+ * .cc-btn-ghost is for low-emphasis secondary links.
779
+ * No outline variant in paywall states — one clear hierarchy.
780
+ */
761
781
  .cc-btn {
762
782
  display: inline-flex;
763
783
  align-items: center;
764
784
  justify-content: center;
765
- gap: 8px;
766
- height: 44px;
767
- padding: 0 20px;
785
+ gap: 7px;
786
+ height: 46px;
787
+ padding: 0 22px;
768
788
  border: none;
769
- border-radius: 8px;
789
+ border-radius: 10px;
770
790
  font-family: ${fontFamily};
771
- font-size: 15px;
791
+ font-size: 14px;
772
792
  font-weight: 600;
773
793
  cursor: pointer;
774
- transition: opacity 0.15s ease, transform 0.1s ease;
794
+ transition: filter 0.15s ease, transform 0.1s ease;
775
795
  width: 100%;
776
- max-width: 320px;
796
+ max-width: 380px;
797
+ letter-spacing: -0.01em;
798
+ white-space: nowrap;
799
+ flex-shrink: 0;
777
800
  }
778
- .cc-btn:hover:not(:disabled) { opacity: 0.88; }
779
- .cc-btn:active:not(:disabled) { transform: scale(0.98); }
801
+ .cc-btn:hover:not(:disabled) { filter: brightness(1.07); }
802
+ .cc-btn:active:not(:disabled) { transform: scale(0.975); }
780
803
  .cc-btn:disabled { opacity: 0.55; cursor: not-allowed; }
781
804
 
782
- .cc-btn-primary {
783
- background: ${primaryColor};
784
- color: #fff;
785
- }
805
+ .cc-btn-primary { background: ${primaryColor}; color: #fff; }
806
+ .cc-btn-secondary { background: #0f172a; color: #fff; }
786
807
 
787
- .cc-btn-secondary {
788
- background: #111827;
789
- color: #fff;
790
- margin-top: 10px;
808
+ /* Ghost: text-link-style secondary action */
809
+ .cc-btn-ghost {
810
+ background: transparent;
811
+ color: #64748b;
812
+ height: 36px;
813
+ font-size: 13px;
814
+ font-weight: 500;
815
+ letter-spacing: 0;
816
+ }
817
+ .cc-btn-ghost:hover:not(:disabled) {
818
+ color: #0f172a;
819
+ background: #f1f5f9;
820
+ filter: none;
791
821
  }
792
822
 
823
+ /* Kept for backwards-compat with paywallTopSlot button items */
793
824
  .cc-btn-outline {
794
825
  background: transparent;
795
- color: #111827;
796
- border: 2px solid #111827;
797
- margin-top: 10px;
826
+ color: #0f172a;
827
+ border: 1.5px solid #cbd5e1;
828
+ }
829
+ .cc-btn-outline:hover:not(:disabled) {
830
+ border-color: #94a3b8;
831
+ filter: none;
832
+ background: #f8fafc;
798
833
  }
799
834
 
835
+ /* ─── Credit badge ───────────────────────────────────────────────────── */
800
836
  .cc-credit-badge {
801
- display: inline-block;
802
- background: #fef3c7;
803
- color: #92400e;
837
+ display: inline-flex;
838
+ align-items: center;
839
+ background: #f1f5f9;
840
+ color: #475569;
804
841
  border-radius: 20px;
805
- padding: 2px 10px;
806
- font-size: 13px;
807
- font-weight: 600;
808
- margin-bottom: 16px;
842
+ padding: 3px 10px;
843
+ font-size: 12px;
844
+ font-weight: 700;
845
+ letter-spacing: 0.01em;
846
+ }
847
+
848
+ /* ─── Inline state description text ─────────────────────────────────── */
849
+ .cc-state-detail {
850
+ font-size: 14px;
851
+ color: #64748b;
852
+ line-height: 1.6;
809
853
  }
810
854
 
855
+ /* ─── Spinner (lives inside .cc-btn-primary while loading) ──────────── */
811
856
  .cc-spinner {
812
- width: 18px; height: 18px;
813
- border: 2px solid rgba(255,255,255,0.4);
814
- border-top-color: #fff;
857
+ display: inline-block;
858
+ width: 17px;
859
+ height: 17px;
860
+ border: 2px solid rgba(255, 255, 255, 0.3);
861
+ border-top-color: rgba(255, 255, 255, 0.95);
815
862
  border-radius: 50%;
816
- animation: cc-spin 0.7s linear infinite;
863
+ animation: cc-spin 0.6s linear infinite;
817
864
  flex-shrink: 0;
818
865
  }
819
866
  @keyframes cc-spin { to { transform: rotate(360deg); } }
820
867
 
868
+ /* ─── "Powered by" attribution ───────────────────────────────────────── */
821
869
  .cc-powered-by {
822
- margin-top: 20px;
823
- font-size: 12px;
824
- color: #9ca3af;
870
+ font-size: 11px;
871
+ color: #94a3b8;
872
+ letter-spacing: 0.01em;
825
873
  }
826
874
  .cc-powered-by a {
827
875
  color: ${primaryColor};
@@ -829,6 +877,13 @@ function getPaywallStyles(primaryColor, fontFamily) {
829
877
  font-weight: 600;
830
878
  }
831
879
  .cc-powered-by a:hover { text-decoration: underline; }
880
+
881
+ /* ─── Mobile: tighter vertical padding ──────────────────────────────── */
882
+ @media (max-width: 480px) {
883
+ .cc-paywall-overlay-slot { padding: 14px 16px 0; gap: 4px; }
884
+ .cc-paywall-overlay-body { padding: 12px 16px 20px; gap: 10px; }
885
+ .cc-slot-heading { font-size: 17px; }
886
+ }
832
887
  `;
833
888
  }
834
889
  function getCommentStyles(primaryColor, fontFamily) {
@@ -1320,7 +1375,23 @@ function createPaywallRenderer(config) {
1320
1375
  gradient.className = 'cc-paywall-overlay-gradient';
1321
1376
  const pageBg = getComputedStyle(document.body).backgroundColor;
1322
1377
  if (pageBg && pageBg !== 'rgba(0, 0, 0, 0)' && pageBg !== 'transparent') {
1323
- gradient.style.background = `linear-gradient(to bottom, transparent 0%, ${pageBg} 100%)`;
1378
+ // Multi-stop gradient so the fade reads as natural depth rather than a
1379
+ // sharp white overlay — same curve as the CSS default, but using the
1380
+ // actual page background colour.
1381
+ gradient.style.background = [
1382
+ 'linear-gradient(to bottom,',
1383
+ `transparent 0%,`,
1384
+ `transparent 18%,`,
1385
+ // Mid-stops approximate a cubic ease-in curve
1386
+ `color-mix(in srgb, ${pageBg} 30%, transparent) 45%,`,
1387
+ `color-mix(in srgb, ${pageBg} 75%, transparent) 68%,`,
1388
+ `${pageBg} 100%`,
1389
+ ')',
1390
+ ].join(' ');
1391
+ // Fallback for browsers without color-mix (pre-2023) — simple 2-stop
1392
+ if (!CSS.supports('color', 'color-mix(in srgb, red 50%, blue)')) {
1393
+ gradient.style.background = `linear-gradient(to bottom, transparent 0%, ${pageBg} 100%)`;
1394
+ }
1324
1395
  }
1325
1396
  panel.appendChild(gradient);
1326
1397
  // Add bottom padding to the content element so the fixed panel
@@ -1331,12 +1402,13 @@ function createPaywallRenderer(config) {
1331
1402
  slot.className = 'cc-paywall-overlay-slot';
1332
1403
  reactRoot = (_a = mountTopSlot(slot, config.paywallTopSlot, config.reactDOM)) !== null && _a !== void 0 ? _a : null;
1333
1404
  panel.appendChild(slot);
1334
- // Only render the "or" divider between slot and body when the slot has content
1405
+ // Visual separator between slot and body — only when the slot has content.
1406
+ // Rendered as a plain horizontal line (no "or" text) so it works regardless
1407
+ // of whether the slot contains competing CTAs or just descriptive copy.
1335
1408
  if (config.paywallTopSlot) {
1336
1409
  const divider = el('div');
1337
1410
  divider.className = 'cc-slot-divider';
1338
- divider.style.cssText = 'margin: 4px auto 0; padding: 0 24px;';
1339
- divider.textContent = 'or';
1411
+ divider.style.cssText = 'margin: 6px 24px 0;';
1340
1412
  panel.appendChild(divider);
1341
1413
  }
1342
1414
  // Our SDK's unlock body
@@ -1353,6 +1425,18 @@ function createPaywallRenderer(config) {
1353
1425
  init();
1354
1426
  if (!body)
1355
1427
  return;
1428
+ // Loading: don't rebuild the DOM — freeze the active button in place.
1429
+ // This prevents the panel from shrinking/shifting when a purchase or
1430
+ // login is in progress, which would be jarring given the panel's fixed position.
1431
+ if (state === 'loading') {
1432
+ setButtonLoading(true);
1433
+ return;
1434
+ }
1435
+ if (state === 'granted') {
1436
+ destroy();
1437
+ return;
1438
+ }
1439
+ // Full state transition — clear and rebuild the body.
1356
1440
  while (body.firstChild)
1357
1441
  body.removeChild(body.firstChild);
1358
1442
  switch (state) {
@@ -1365,22 +1449,19 @@ function createPaywallRenderer(config) {
1365
1449
  case 'insufficient':
1366
1450
  renderInsufficient(body, callbacks, (_b = meta === null || meta === void 0 ? void 0 : meta.requiredCredits) !== null && _b !== void 0 ? _b : null, (_c = meta === null || meta === void 0 ? void 0 : meta.creditBalance) !== null && _c !== void 0 ? _c : null);
1367
1451
  break;
1368
- case 'loading':
1369
- renderLoading(body);
1370
- break;
1371
- case 'granted':
1372
- destroy();
1373
- break;
1374
1452
  }
1375
1453
  }
1454
+ // ── State renderers ────────────────────────────────────────────────────────
1376
1455
  function renderLogin(parent, cb) {
1377
- // Only show the heading/description in inline mode; in overlay mode the
1378
- // client's top slot already provides the article context.
1456
+ // In overlay mode the slot already provides article context, so the body
1457
+ // just needs the CTA. In inline mode we add heading + description.
1379
1458
  if (config.paywallMode === 'inline') {
1380
1459
  parent.appendChild(el('h2', 'This article requires a subscription'));
1381
- parent.appendChild(el('p', 'Log in with your Content Credits account to unlock this article.'));
1460
+ const detail = el('p', 'Sign in to your Content Credits account to unlock this article.');
1461
+ detail.className = 'cc-state-detail';
1462
+ parent.appendChild(detail);
1382
1463
  }
1383
- const btn = el('button', 'Login & Unlock with Content Credits');
1464
+ const btn = el('button', 'Sign in to read');
1384
1465
  btn.className = 'cc-btn cc-btn-primary';
1385
1466
  btn.addEventListener('click', () => { void cb.onLogin(); });
1386
1467
  parent.appendChild(btn);
@@ -1389,18 +1470,16 @@ function createPaywallRenderer(config) {
1389
1470
  function renderPurchase(parent, cb, credits) {
1390
1471
  if (config.paywallMode === 'inline') {
1391
1472
  parent.appendChild(el('h2', 'Unlock this article'));
1392
- if (credits !== null) {
1393
- const badge = el('span', `${credits} credit${credits !== 1 ? 's' : ''}`);
1394
- badge.className = 'cc-credit-badge';
1395
- parent.appendChild(badge);
1396
- }
1397
- parent.appendChild(el('p', 'Use your Content Credits balance to instantly access this premium article.'));
1473
+ const detail = el('p', 'Use your Content Credits balance to instantly access this article.');
1474
+ detail.className = 'cc-state-detail';
1475
+ parent.appendChild(detail);
1398
1476
  }
1477
+ // Credits shown inline in the button label — clear and scannable.
1399
1478
  const label = credits !== null
1400
- ? `Unlock Just This Story · ${credits} Credit${credits !== 1 ? 's' : ''}`
1401
- : 'Unlock Just This Story';
1479
+ ? `Unlock · ${credits} credit${credits !== 1 ? 's' : ''}`
1480
+ : 'Unlock article';
1402
1481
  const btn = el('button', label);
1403
- btn.className = 'cc-btn cc-btn-outline';
1482
+ btn.className = 'cc-btn cc-btn-primary';
1404
1483
  btn.addEventListener('click', () => { void cb.onPurchase(); });
1405
1484
  parent.appendChild(btn);
1406
1485
  parent.appendChild(poweredBy());
@@ -1409,26 +1488,21 @@ function createPaywallRenderer(config) {
1409
1488
  if (config.paywallMode === 'inline') {
1410
1489
  parent.appendChild(el('h2', 'Not enough credits'));
1411
1490
  }
1412
- const detail = required !== null && available !== null
1413
- ? `You need ${required} credit${required !== 1 ? 's' : ''} but only have ${available}.`
1414
- : 'You don\'t have enough credits to unlock this article.';
1415
- parent.appendChild(el('p', detail));
1416
- const btn = el('button', 'Buy More Credits');
1491
+ const detail = el('p');
1492
+ detail.className = 'cc-state-detail';
1493
+ if (required !== null && available !== null) {
1494
+ detail.textContent = `This article costs ${required} credit${required !== 1 ? 's' : ''} — you have ${available}.`;
1495
+ }
1496
+ else {
1497
+ detail.textContent = "You don't have enough credits to unlock this article.";
1498
+ }
1499
+ parent.appendChild(detail);
1500
+ const btn = el('button', 'Top up credits');
1417
1501
  btn.className = 'cc-btn cc-btn-primary';
1418
1502
  btn.addEventListener('click', () => cb.onBuyMoreCredits());
1419
1503
  parent.appendChild(btn);
1420
1504
  parent.appendChild(poweredBy());
1421
1505
  }
1422
- function renderLoading(parent) {
1423
- const btn = el('button');
1424
- btn.className = 'cc-btn cc-btn-outline';
1425
- btn.disabled = true;
1426
- const spinner = el('span');
1427
- spinner.className = 'cc-spinner';
1428
- btn.appendChild(spinner);
1429
- btn.appendChild(document.createTextNode(' Processing…'));
1430
- parent.appendChild(btn);
1431
- }
1432
1506
  function poweredBy() {
1433
1507
  const div = el('div');
1434
1508
  div.className = 'cc-powered-by';
@@ -1440,6 +1514,7 @@ function createPaywallRenderer(config) {
1440
1514
  div.appendChild(link);
1441
1515
  return div;
1442
1516
  }
1517
+ // ── Button loading state ───────────────────────────────────────────────────
1443
1518
  function setButtonLoading(loading) {
1444
1519
  if (!body)
1445
1520
  return;
@@ -1448,6 +1523,8 @@ function createPaywallRenderer(config) {
1448
1523
  return;
1449
1524
  btn.disabled = loading;
1450
1525
  if (loading) {
1526
+ // Replace button content with spinner + label, preserving button size.
1527
+ // The spinner is white on the primary colour background — matches all states.
1451
1528
  const spinner = el('span');
1452
1529
  spinner.className = 'cc-spinner';
1453
1530
  setTextContent(btn, '');
@@ -1455,6 +1532,7 @@ function createPaywallRenderer(config) {
1455
1532
  btn.appendChild(document.createTextNode(' Processing…'));
1456
1533
  }
1457
1534
  }
1535
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
1458
1536
  function destroy() {
1459
1537
  reactRoot === null || reactRoot === void 0 ? void 0 : reactRoot.unmount();
1460
1538
  reactRoot = null;
@@ -3225,7 +3303,7 @@ class ContentCredits {
3225
3303
  }
3226
3304
  /** SDK version string. */
3227
3305
  static get version() {
3228
- return "2.14.0";
3306
+ return "2.16.0";
3229
3307
  }
3230
3308
  }
3231
3309
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────