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