@crediball/elements 0.10.0 → 0.12.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.
@@ -2,22 +2,33 @@
2
2
  import {
3
3
  getCrediballClient,
4
4
  loadRemoteFont,
5
- themeToCssVars
5
+ themeToCssVars,
6
+ getSystemColorScheme,
7
+ watchSystemColorScheme
6
8
  } from "@crediball/core";
7
9
  var _CrediballElement = class _CrediballElement extends HTMLElement {
8
10
  constructor() {
9
11
  super(...arguments);
10
12
  this.client = null;
11
13
  this.unsubscribe = null;
14
+ this.unwatchSystemColorScheme = null;
12
15
  }
13
16
  static get observedAttributes() {
14
- return [..._CrediballElement.CLIENT_ATTRS];
17
+ return [..._CrediballElement.CLIENT_ATTRS, "color-scheme"];
15
18
  }
16
19
  connectedCallback() {
17
20
  this.connectClient();
21
+ this.unwatchSystemColorScheme = watchSystemColorScheme(() => {
22
+ if (!this.explicitColorScheme()) {
23
+ this.applyRemoteTheme();
24
+ this.render();
25
+ }
26
+ });
18
27
  }
19
28
  disconnectedCallback() {
20
29
  this.teardownClient();
30
+ this.unwatchSystemColorScheme?.();
31
+ this.unwatchSystemColorScheme = null;
21
32
  }
22
33
  /**
23
34
  * Only reconnect the shared client when a connection-relevant attribute
@@ -29,10 +40,21 @@ var _CrediballElement = class _CrediballElement extends HTMLElement {
29
40
  if (!this.isConnected) return;
30
41
  if (_CrediballElement.CLIENT_ATTRS.has(name)) {
31
42
  this.connectClient();
43
+ } else if (name === "color-scheme") {
44
+ this.applyRemoteTheme();
45
+ this.render();
32
46
  } else {
33
47
  this.render();
34
48
  }
35
49
  }
50
+ explicitColorScheme() {
51
+ const attr = this.getAttribute("color-scheme");
52
+ return attr === "light" || attr === "dark" ? attr : null;
53
+ }
54
+ /** "light"/"dark" attribute wins; otherwise follows the live OS/browser preference. */
55
+ resolvedColorScheme() {
56
+ return this.explicitColorScheme() ?? getSystemColorScheme();
57
+ }
36
58
  connectClient() {
37
59
  this.teardownClient();
38
60
  const publishableKey = this.getAttribute("publishable-key");
@@ -59,12 +81,18 @@ var _CrediballElement = class _CrediballElement extends HTMLElement {
59
81
  * the shadow boundary, so the shadow-DOM styles pick them up. On by default;
60
82
  * set `apply-remote-theme="false"` to theme from your own CSS instead. The
61
83
  * published theme takes precedence over host `--crediball-*` vars (dashboard wins).
84
+ *
85
+ * If a dark palette is configured on the dashboard, it's applied whenever
86
+ * the resolved color scheme is "dark" — following the OS/browser's
87
+ * `prefers-color-scheme` live, or a `color-scheme="light"|"dark"` attribute
88
+ * you set explicitly (wins over the OS setting, for an app with its own
89
+ * in-app theme toggle).
62
90
  */
63
91
  applyRemoteTheme() {
64
92
  if (this.getAttribute("apply-remote-theme") === "false") return;
65
93
  const theme = this.getSnapshot()?.packages?.ui?.theme;
66
94
  if (!theme) return;
67
- for (const [cssVar, value] of Object.entries(themeToCssVars(theme))) {
95
+ for (const [cssVar, value] of Object.entries(themeToCssVars(theme, this.resolvedColorScheme()))) {
68
96
  this.style.setProperty(cssVar, value);
69
97
  }
70
98
  if (theme.fontUrl) loadRemoteFont(theme.fontUrl);
@@ -217,13 +245,24 @@ var STYLE = `
217
245
  .card {
218
246
  width: 100%;
219
247
  max-width: 420px;
248
+ /* Cap to the overlay's own (viewport-correct, since it's fixed/inset:0)
249
+ content box and scroll internally past that \u2014 otherwise a host with
250
+ many optional sections enabled (balance, subscribe, promo code,
251
+ packages, custom amount, auto top-up, referral) can produce a card
252
+ taller than the mobile viewport, which the centered overlay then
253
+ clips top and bottom with no way to reach the rest. */
254
+ max-height: 100%;
255
+ overflow-y: auto;
256
+ -webkit-overflow-scrolling: touch;
257
+ overscroll-behavior: contain;
258
+ box-sizing: border-box;
220
259
  padding: 32px;
221
260
  border-radius: var(--crediball-radius-card, 18px);
222
261
  background: var(--crediball-canvas, #ffffff);
223
262
  color: var(--crediball-ink, #1d1d1f);
224
263
  }
225
264
  .balance-row { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--crediball-hairline, #e0e0e0); }
226
- .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--crediball-ink-muted, #7a7a7a); margin-bottom: 4px; }
265
+ .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; color: var(--crediball-ink-muted, #7a7a7a); margin-bottom: 4px; }
227
266
  .balance-value { font-size: 22px; font-weight: 600; }
228
267
  h3 { margin: 0; font-size: 24px; font-weight: 600; letter-spacing: -0.01em; }
229
268
  p.desc { margin: 8px 0 0; font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
@@ -236,14 +275,17 @@ var STYLE = `
236
275
  display: flex;
237
276
  justify-content: space-between;
238
277
  align-items: center;
278
+ gap: 16px;
239
279
  background: var(--crediball-accent, #0066cc);
240
280
  color: var(--crediball-on-accent, #ffffff);
241
281
  font: inherit;
242
- font-size: 17px;
243
282
  padding: 11px 22px;
244
283
  border-radius: var(--crediball-radius, 9999px);
245
284
  }
246
- button.option .meta { opacity: 0.85; font-size: 14px; }
285
+ .option-left { display: flex; flex-direction: column; align-items: flex-start; flex: 1; min-width: 0; }
286
+ .option-name { font-size: 17px; font-weight: 600; line-height: 1.2; }
287
+ .option-credits { font-size: 13px; opacity: 0.85; line-height: 1.2; }
288
+ .option-price { font-size: 17px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
247
289
  .custom-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
248
290
  .custom-row .sym { font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
249
291
  .custom-row input {
@@ -304,6 +346,8 @@ var STYLE = `
304
346
  color: var(--crediball-ink, #1d1d1f);
305
347
  outline: none;
306
348
  }
349
+ .promo-code-input.invalid { border-color: var(--crediball-accent, #0066cc); }
350
+ .promo-code-error { margin: 6px 0 0; font-size: 12px; color: var(--crediball-accent, #0066cc); }
307
351
  .autotopup { margin-top: 16px; }
308
352
  .autotopup-toggle {
309
353
  appearance: none; border: none; cursor: pointer; background: transparent; padding: 0;
@@ -326,7 +370,7 @@ var STYLE = `
326
370
  }
327
371
  .autotopup-hint { margin: 6px 0 0; font-size: 11px; color: var(--crediball-ink-muted, #7a7a7a); }
328
372
  .referral { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--crediball-hairline, #e0e0e0); }
329
- .referral-label { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: var(--crediball-ink-muted, #7a7a7a); }
373
+ .referral-label { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: var(--crediball-ink-muted, #7a7a7a); white-space: pre-line; }
330
374
  .referral-row { display: flex; gap: 8px; align-items: center; }
331
375
  .referral-row .link {
332
376
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -414,6 +458,10 @@ var CrediballPaywallElement = class extends CrediballElement {
414
458
  const plans = pkgs?.activeSubscription ? [] : pkgs?.subscriptionPlans ?? [];
415
459
  const custom = pkgs?.customTopup;
416
460
  const showCustom = Boolean(custom?.enabled);
461
+ const referralReward = this.referral?.rewardCredits ?? 0;
462
+ const referredReward = this.referral?.referredRewardCredits ?? 0;
463
+ const defaultReferralLabel = referredReward > 0 ? "Refer a friend.\nYou earn {credits} credits and your friend {referredCredits} credits" : "Refer a friend \xB7 earn {credits} credits";
464
+ const referralLabel = (this.getAttribute("referral-label") ?? defaultReferralLabel).replace("{credits}", formatCredits(referralReward)).replace("{referredCredits}", formatCredits(referredReward));
417
465
  this.shadow.innerHTML = `
418
466
  <style>${STYLE}</style>
419
467
  <div class="overlay" part="overlay">
@@ -428,18 +476,23 @@ var CrediballPaywallElement = class extends CrediballElement {
428
476
  ${plans.map(
429
477
  (p) => `
430
478
  <button class="option" data-kind="plan" data-id="${escapeHtml(p.id)}">
431
- <span>${escapeHtml(p.name)}</span>
432
- <span class="meta">${formatMoney(p.price, currency)}/${PERIOD_SUFFIX[p.period] ?? p.period} \xB7 ${formatCredits(p.credits)} cr</span>
479
+ <div class="option-left">
480
+ <div class="option-name">${escapeHtml(p.name)}</div>
481
+ <div class="option-credits">${formatCredits(p.credits)} credits</div>
482
+ </div>
483
+ <div class="option-price">${formatMoney(p.price, currency)}/${PERIOD_SUFFIX[p.period] ?? p.period}</div>
433
484
  </button>`
434
485
  ).join("")}
435
486
  </div>` : ""}
436
- ${this.renderPromoCode(Boolean(pkgs?.hasActiveCheckoutPromotion))}
437
487
  <div class="list">
438
488
  ${packages.length ? packages.map(
439
489
  (p) => `
440
490
  <button class="option" data-kind="package" data-id="${escapeHtml(p.id)}">
441
- <span>${escapeHtml(p.name)}</span>
442
- <span class="meta">${formatMoney(p.price, currency)} \xB7 ${formatCredits(p.credits)} cr</span>
491
+ <div class="option-left">
492
+ <div class="option-name">${escapeHtml(p.name)}</div>
493
+ <div class="option-credits">${formatCredits(p.credits)} credits</div>
494
+ </div>
495
+ <div class="option-price">${formatMoney(p.price, currency)}</div>
443
496
  </button>`
444
497
  ).join("") : showCustom ? "" : legacyAmounts.map(
445
498
  (a) => `
@@ -454,16 +507,12 @@ var CrediballPaywallElement = class extends CrediballElement {
454
507
  <button class="custom-add" type="button">${escapeHtml(addButtonText)}</button>
455
508
  </div>
456
509
  <p class="custom-preview" hidden></p>` : ""}
457
- ${this.renderAutoTopup(packages, pkgs?.autoTopup, currency)}
510
+ ${this.renderPromoCode(Boolean(pkgs?.hasActiveCheckoutPromotion))}
458
511
  <p class="error" hidden></p>
512
+ ${this.renderAutoTopup(packages, pkgs?.autoTopup, currency)}
459
513
  <button class="close" part="close" type="button">Not now</button>
460
514
  ${this.referral?.link ? `<div class="referral">
461
- <div class="referral-label">${escapeHtml(
462
- (this.getAttribute("referral-label") ?? "Refer a friend \xB7 earn {credits} credits").replace(
463
- "{credits}",
464
- formatCredits(this.referral.rewardCredits ?? 0)
465
- )
466
- )}</div>
515
+ <div class="referral-label">${escapeHtml(referralLabel)}</div>
467
516
  <div class="referral-row">
468
517
  <div class="link" title="${escapeHtml(this.referral.link)}">${escapeHtml(this.referral.link)}</div>
469
518
  <button class="referral-copy" type="button">${this.referralCopied ? "Copied!" : "Copy"}</button>
@@ -505,6 +554,24 @@ var CrediballPaywallElement = class extends CrediballElement {
505
554
  this.promoCodeOpen = true;
506
555
  this.render();
507
556
  });
557
+ const promoInput = this.shadow.querySelector(".promo-code-input");
558
+ const promoErrorEl = this.shadow.querySelector(".promo-code-error");
559
+ if (promoInput && promoErrorEl) {
560
+ let promoTouched = false;
561
+ const checkPromoFormat = () => {
562
+ const value = promoInput.value.trim();
563
+ const valid = !value || /^[A-Za-z0-9_-]+$/.test(value);
564
+ const showError2 = promoTouched && !valid;
565
+ promoInput.classList.toggle("invalid", showError2);
566
+ promoErrorEl.hidden = !showError2;
567
+ if (showError2) promoErrorEl.textContent = "Promo codes only contain letters, numbers, - and _.";
568
+ };
569
+ promoInput.addEventListener("blur", () => {
570
+ promoTouched = true;
571
+ checkPromoFormat();
572
+ });
573
+ promoInput.addEventListener("input", checkPromoFormat);
574
+ }
508
575
  if (showCustom) {
509
576
  const input = this.shadow.querySelector(".custom-input");
510
577
  const addBtn = this.shadow.querySelector(".custom-add");
@@ -572,8 +639,9 @@ var CrediballPaywallElement = class extends CrediballElement {
572
639
  * once the app has an active Checkout promotion (same as the custom-amount
573
640
  * field auto-following the dashboard's custom-topup config) — the
574
641
  * `allow-promo-code` attribute is only needed to force it on before you've
575
- * set one up. Not validated client-side an invalid code simply doesn't
576
- * grant a bonus when the top-up is processed. */
642
+ * set one up. Format-only client check (flags an obvious typo/paste
643
+ * mistake on blur, see wireUpPromoCode()) — whether the code is actually
644
+ * active/redeemable is only known server-side when a purchase is made. */
577
645
  renderPromoCode(hasActiveCheckoutPromotion) {
578
646
  if (!this.hasAttribute("allow-promo-code") && !hasActiveCheckoutPromotion) return "";
579
647
  const label = this.getAttribute("promo-code-label") ?? "Have a promo code?";
@@ -585,6 +653,7 @@ var CrediballPaywallElement = class extends CrediballElement {
585
653
  return `<div class="promo-code">
586
654
  <div class="promo-code-label">${escapeHtml(label)}</div>
587
655
  <input class="promo-code-input" placeholder="PROMOCODE" aria-label="Promo code" />
656
+ <p class="promo-code-error" hidden></p>
588
657
  </div>`;
589
658
  }
590
659
  /** The current promo code input value (uppercased, trimmed), or undefined
@@ -384,6 +384,27 @@ var Crediball = (() => {
384
384
  }
385
385
 
386
386
  // ../core/dist/theme.js
387
+ function getSystemColorScheme() {
388
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
389
+ return "light";
390
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
391
+ }
392
+ function watchSystemColorScheme(cb) {
393
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
394
+ return () => {
395
+ };
396
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
397
+ const handler = () => cb(mql.matches ? "dark" : "light");
398
+ mql.addEventListener("change", handler);
399
+ return () => mql.removeEventListener("change", handler);
400
+ }
401
+ function resolveUiTheme(theme, scheme) {
402
+ if (!theme)
403
+ return theme ?? null;
404
+ if (scheme !== "dark" || !theme.dark)
405
+ return theme;
406
+ return { ...theme, ...theme.dark };
407
+ }
387
408
  var THEME_CSS_VAR_ENTRIES = [
388
409
  ["accent", "--crediball-accent", String],
389
410
  ["onAccent", "--crediball-on-accent", String],
@@ -394,12 +415,13 @@ var Crediball = (() => {
394
415
  ["radiusCard", "--crediball-radius-card", (v) => `${v}px`],
395
416
  ["fontStack", "--crediball-font", String]
396
417
  ];
397
- function themeToCssVars(theme) {
418
+ function themeToCssVars(theme, scheme = "light") {
398
419
  const vars = {};
399
- if (!theme)
420
+ const resolved = resolveUiTheme(theme, scheme);
421
+ if (!resolved)
400
422
  return vars;
401
423
  for (const [key, cssVar, fmt] of THEME_CSS_VAR_ENTRIES) {
402
- const value = theme[key];
424
+ const value = resolved[key];
403
425
  if (value != null && value !== "")
404
426
  vars[cssVar] = fmt(value);
405
427
  }
@@ -428,15 +450,24 @@ var Crediball = (() => {
428
450
  super(...arguments);
429
451
  this.client = null;
430
452
  this.unsubscribe = null;
453
+ this.unwatchSystemColorScheme = null;
431
454
  }
432
455
  static get observedAttributes() {
433
- return [..._CrediballElement.CLIENT_ATTRS];
456
+ return [..._CrediballElement.CLIENT_ATTRS, "color-scheme"];
434
457
  }
435
458
  connectedCallback() {
436
459
  this.connectClient();
460
+ this.unwatchSystemColorScheme = watchSystemColorScheme(() => {
461
+ if (!this.explicitColorScheme()) {
462
+ this.applyRemoteTheme();
463
+ this.render();
464
+ }
465
+ });
437
466
  }
438
467
  disconnectedCallback() {
439
468
  this.teardownClient();
469
+ this.unwatchSystemColorScheme?.();
470
+ this.unwatchSystemColorScheme = null;
440
471
  }
441
472
  /**
442
473
  * Only reconnect the shared client when a connection-relevant attribute
@@ -448,10 +479,21 @@ var Crediball = (() => {
448
479
  if (!this.isConnected) return;
449
480
  if (_CrediballElement.CLIENT_ATTRS.has(name)) {
450
481
  this.connectClient();
482
+ } else if (name === "color-scheme") {
483
+ this.applyRemoteTheme();
484
+ this.render();
451
485
  } else {
452
486
  this.render();
453
487
  }
454
488
  }
489
+ explicitColorScheme() {
490
+ const attr = this.getAttribute("color-scheme");
491
+ return attr === "light" || attr === "dark" ? attr : null;
492
+ }
493
+ /** "light"/"dark" attribute wins; otherwise follows the live OS/browser preference. */
494
+ resolvedColorScheme() {
495
+ return this.explicitColorScheme() ?? getSystemColorScheme();
496
+ }
455
497
  connectClient() {
456
498
  this.teardownClient();
457
499
  const publishableKey = this.getAttribute("publishable-key");
@@ -478,12 +520,18 @@ var Crediball = (() => {
478
520
  * the shadow boundary, so the shadow-DOM styles pick them up. On by default;
479
521
  * set `apply-remote-theme="false"` to theme from your own CSS instead. The
480
522
  * published theme takes precedence over host `--crediball-*` vars (dashboard wins).
523
+ *
524
+ * If a dark palette is configured on the dashboard, it's applied whenever
525
+ * the resolved color scheme is "dark" — following the OS/browser's
526
+ * `prefers-color-scheme` live, or a `color-scheme="light"|"dark"` attribute
527
+ * you set explicitly (wins over the OS setting, for an app with its own
528
+ * in-app theme toggle).
481
529
  */
482
530
  applyRemoteTheme() {
483
531
  if (this.getAttribute("apply-remote-theme") === "false") return;
484
532
  const theme = this.getSnapshot()?.packages?.ui?.theme;
485
533
  if (!theme) return;
486
- for (const [cssVar, value] of Object.entries(themeToCssVars(theme))) {
534
+ for (const [cssVar, value] of Object.entries(themeToCssVars(theme, this.resolvedColorScheme()))) {
487
535
  this.style.setProperty(cssVar, value);
488
536
  }
489
537
  if (theme.fontUrl) loadRemoteFont(theme.fontUrl);
@@ -631,13 +679,24 @@ var Crediball = (() => {
631
679
  .card {
632
680
  width: 100%;
633
681
  max-width: 420px;
682
+ /* Cap to the overlay's own (viewport-correct, since it's fixed/inset:0)
683
+ content box and scroll internally past that \u2014 otherwise a host with
684
+ many optional sections enabled (balance, subscribe, promo code,
685
+ packages, custom amount, auto top-up, referral) can produce a card
686
+ taller than the mobile viewport, which the centered overlay then
687
+ clips top and bottom with no way to reach the rest. */
688
+ max-height: 100%;
689
+ overflow-y: auto;
690
+ -webkit-overflow-scrolling: touch;
691
+ overscroll-behavior: contain;
692
+ box-sizing: border-box;
634
693
  padding: 32px;
635
694
  border-radius: var(--crediball-radius-card, 18px);
636
695
  background: var(--crediball-canvas, #ffffff);
637
696
  color: var(--crediball-ink, #1d1d1f);
638
697
  }
639
698
  .balance-row { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--crediball-hairline, #e0e0e0); }
640
- .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--crediball-ink-muted, #7a7a7a); margin-bottom: 4px; }
699
+ .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; color: var(--crediball-ink-muted, #7a7a7a); margin-bottom: 4px; }
641
700
  .balance-value { font-size: 22px; font-weight: 600; }
642
701
  h3 { margin: 0; font-size: 24px; font-weight: 600; letter-spacing: -0.01em; }
643
702
  p.desc { margin: 8px 0 0; font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
@@ -650,14 +709,17 @@ var Crediball = (() => {
650
709
  display: flex;
651
710
  justify-content: space-between;
652
711
  align-items: center;
712
+ gap: 16px;
653
713
  background: var(--crediball-accent, #0066cc);
654
714
  color: var(--crediball-on-accent, #ffffff);
655
715
  font: inherit;
656
- font-size: 17px;
657
716
  padding: 11px 22px;
658
717
  border-radius: var(--crediball-radius, 9999px);
659
718
  }
660
- button.option .meta { opacity: 0.85; font-size: 14px; }
719
+ .option-left { display: flex; flex-direction: column; align-items: flex-start; flex: 1; min-width: 0; }
720
+ .option-name { font-size: 17px; font-weight: 600; line-height: 1.2; }
721
+ .option-credits { font-size: 13px; opacity: 0.85; line-height: 1.2; }
722
+ .option-price { font-size: 17px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
661
723
  .custom-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
662
724
  .custom-row .sym { font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
663
725
  .custom-row input {
@@ -718,6 +780,8 @@ var Crediball = (() => {
718
780
  color: var(--crediball-ink, #1d1d1f);
719
781
  outline: none;
720
782
  }
783
+ .promo-code-input.invalid { border-color: var(--crediball-accent, #0066cc); }
784
+ .promo-code-error { margin: 6px 0 0; font-size: 12px; color: var(--crediball-accent, #0066cc); }
721
785
  .autotopup { margin-top: 16px; }
722
786
  .autotopup-toggle {
723
787
  appearance: none; border: none; cursor: pointer; background: transparent; padding: 0;
@@ -740,7 +804,7 @@ var Crediball = (() => {
740
804
  }
741
805
  .autotopup-hint { margin: 6px 0 0; font-size: 11px; color: var(--crediball-ink-muted, #7a7a7a); }
742
806
  .referral { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--crediball-hairline, #e0e0e0); }
743
- .referral-label { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: var(--crediball-ink-muted, #7a7a7a); }
807
+ .referral-label { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: var(--crediball-ink-muted, #7a7a7a); white-space: pre-line; }
744
808
  .referral-row { display: flex; gap: 8px; align-items: center; }
745
809
  .referral-row .link {
746
810
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -828,6 +892,10 @@ var Crediball = (() => {
828
892
  const plans = pkgs?.activeSubscription ? [] : pkgs?.subscriptionPlans ?? [];
829
893
  const custom = pkgs?.customTopup;
830
894
  const showCustom = Boolean(custom?.enabled);
895
+ const referralReward = this.referral?.rewardCredits ?? 0;
896
+ const referredReward = this.referral?.referredRewardCredits ?? 0;
897
+ const defaultReferralLabel = referredReward > 0 ? "Refer a friend.\nYou earn {credits} credits and your friend {referredCredits} credits" : "Refer a friend \xB7 earn {credits} credits";
898
+ const referralLabel = (this.getAttribute("referral-label") ?? defaultReferralLabel).replace("{credits}", formatCredits(referralReward)).replace("{referredCredits}", formatCredits(referredReward));
831
899
  this.shadow.innerHTML = `
832
900
  <style>${STYLE}</style>
833
901
  <div class="overlay" part="overlay">
@@ -842,18 +910,23 @@ var Crediball = (() => {
842
910
  ${plans.map(
843
911
  (p) => `
844
912
  <button class="option" data-kind="plan" data-id="${escapeHtml(p.id)}">
845
- <span>${escapeHtml(p.name)}</span>
846
- <span class="meta">${formatMoney(p.price, currency)}/${PERIOD_SUFFIX[p.period] ?? p.period} \xB7 ${formatCredits(p.credits)} cr</span>
913
+ <div class="option-left">
914
+ <div class="option-name">${escapeHtml(p.name)}</div>
915
+ <div class="option-credits">${formatCredits(p.credits)} credits</div>
916
+ </div>
917
+ <div class="option-price">${formatMoney(p.price, currency)}/${PERIOD_SUFFIX[p.period] ?? p.period}</div>
847
918
  </button>`
848
919
  ).join("")}
849
920
  </div>` : ""}
850
- ${this.renderPromoCode(Boolean(pkgs?.hasActiveCheckoutPromotion))}
851
921
  <div class="list">
852
922
  ${packages.length ? packages.map(
853
923
  (p) => `
854
924
  <button class="option" data-kind="package" data-id="${escapeHtml(p.id)}">
855
- <span>${escapeHtml(p.name)}</span>
856
- <span class="meta">${formatMoney(p.price, currency)} \xB7 ${formatCredits(p.credits)} cr</span>
925
+ <div class="option-left">
926
+ <div class="option-name">${escapeHtml(p.name)}</div>
927
+ <div class="option-credits">${formatCredits(p.credits)} credits</div>
928
+ </div>
929
+ <div class="option-price">${formatMoney(p.price, currency)}</div>
857
930
  </button>`
858
931
  ).join("") : showCustom ? "" : legacyAmounts.map(
859
932
  (a) => `
@@ -868,16 +941,12 @@ var Crediball = (() => {
868
941
  <button class="custom-add" type="button">${escapeHtml(addButtonText)}</button>
869
942
  </div>
870
943
  <p class="custom-preview" hidden></p>` : ""}
871
- ${this.renderAutoTopup(packages, pkgs?.autoTopup, currency)}
944
+ ${this.renderPromoCode(Boolean(pkgs?.hasActiveCheckoutPromotion))}
872
945
  <p class="error" hidden></p>
946
+ ${this.renderAutoTopup(packages, pkgs?.autoTopup, currency)}
873
947
  <button class="close" part="close" type="button">Not now</button>
874
948
  ${this.referral?.link ? `<div class="referral">
875
- <div class="referral-label">${escapeHtml(
876
- (this.getAttribute("referral-label") ?? "Refer a friend \xB7 earn {credits} credits").replace(
877
- "{credits}",
878
- formatCredits(this.referral.rewardCredits ?? 0)
879
- )
880
- )}</div>
949
+ <div class="referral-label">${escapeHtml(referralLabel)}</div>
881
950
  <div class="referral-row">
882
951
  <div class="link" title="${escapeHtml(this.referral.link)}">${escapeHtml(this.referral.link)}</div>
883
952
  <button class="referral-copy" type="button">${this.referralCopied ? "Copied!" : "Copy"}</button>
@@ -919,6 +988,24 @@ var Crediball = (() => {
919
988
  this.promoCodeOpen = true;
920
989
  this.render();
921
990
  });
991
+ const promoInput = this.shadow.querySelector(".promo-code-input");
992
+ const promoErrorEl = this.shadow.querySelector(".promo-code-error");
993
+ if (promoInput && promoErrorEl) {
994
+ let promoTouched = false;
995
+ const checkPromoFormat = () => {
996
+ const value = promoInput.value.trim();
997
+ const valid = !value || /^[A-Za-z0-9_-]+$/.test(value);
998
+ const showError2 = promoTouched && !valid;
999
+ promoInput.classList.toggle("invalid", showError2);
1000
+ promoErrorEl.hidden = !showError2;
1001
+ if (showError2) promoErrorEl.textContent = "Promo codes only contain letters, numbers, - and _.";
1002
+ };
1003
+ promoInput.addEventListener("blur", () => {
1004
+ promoTouched = true;
1005
+ checkPromoFormat();
1006
+ });
1007
+ promoInput.addEventListener("input", checkPromoFormat);
1008
+ }
922
1009
  if (showCustom) {
923
1010
  const input = this.shadow.querySelector(".custom-input");
924
1011
  const addBtn = this.shadow.querySelector(".custom-add");
@@ -986,8 +1073,9 @@ var Crediball = (() => {
986
1073
  * once the app has an active Checkout promotion (same as the custom-amount
987
1074
  * field auto-following the dashboard's custom-topup config) — the
988
1075
  * `allow-promo-code` attribute is only needed to force it on before you've
989
- * set one up. Not validated client-side an invalid code simply doesn't
990
- * grant a bonus when the top-up is processed. */
1076
+ * set one up. Format-only client check (flags an obvious typo/paste
1077
+ * mistake on blur, see wireUpPromoCode()) — whether the code is actually
1078
+ * active/redeemable is only known server-side when a purchase is made. */
991
1079
  renderPromoCode(hasActiveCheckoutPromotion) {
992
1080
  if (!this.hasAttribute("allow-promo-code") && !hasActiveCheckoutPromotion) return "";
993
1081
  const label = this.getAttribute("promo-code-label") ?? "Have a promo code?";
@@ -999,6 +1087,7 @@ var Crediball = (() => {
999
1087
  return `<div class="promo-code">
1000
1088
  <div class="promo-code-label">${escapeHtml(label)}</div>
1001
1089
  <input class="promo-code-input" placeholder="PROMOCODE" aria-label="Promo code" />
1090
+ <p class="promo-code-error" hidden></p>
1002
1091
  </div>`;
1003
1092
  }
1004
1093
  /** The current promo code input value (uppercased, trimmed), or undefined
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CrediballClient, CrediballSnapshot } from '@crediball/core';
1
+ import { CrediballClient, ColorScheme, CrediballSnapshot } from '@crediball/core';
2
2
 
3
3
  /**
4
4
  * Shared base for every <crediball-*> element: reads config from attributes,
@@ -12,6 +12,7 @@ import { CrediballClient, CrediballSnapshot } from '@crediball/core';
12
12
  declare abstract class CrediballElement extends HTMLElement {
13
13
  protected client: CrediballClient | null;
14
14
  private unsubscribe;
15
+ private unwatchSystemColorScheme;
15
16
  private static readonly CLIENT_ATTRS;
16
17
  static get observedAttributes(): string[];
17
18
  connectedCallback(): void;
@@ -23,6 +24,9 @@ declare abstract class CrediballElement extends HTMLElement {
23
24
  * those should just re-render, not tear down and refetch the client.
24
25
  */
25
26
  attributeChangedCallback(name: string): void;
27
+ private explicitColorScheme;
28
+ /** "light"/"dark" attribute wins; otherwise follows the live OS/browser preference. */
29
+ protected resolvedColorScheme(): ColorScheme;
26
30
  private connectClient;
27
31
  /**
28
32
  * Apply the developer-published theme (served alongside packages) as
@@ -30,6 +34,12 @@ declare abstract class CrediballElement extends HTMLElement {
30
34
  * the shadow boundary, so the shadow-DOM styles pick them up. On by default;
31
35
  * set `apply-remote-theme="false"` to theme from your own CSS instead. The
32
36
  * published theme takes precedence over host `--crediball-*` vars (dashboard wins).
37
+ *
38
+ * If a dark palette is configured on the dashboard, it's applied whenever
39
+ * the resolved color scheme is "dark" — following the OS/browser's
40
+ * `prefers-color-scheme` live, or a `color-scheme="light"|"dark"` attribute
41
+ * you set explicitly (wins over the OS setting, for an app with its own
42
+ * in-app theme toggle).
33
43
  */
34
44
  private applyRemoteTheme;
35
45
  private teardownClient;
@@ -127,8 +137,9 @@ declare class CrediballPaywallElement extends CrediballElement {
127
137
  * once the app has an active Checkout promotion (same as the custom-amount
128
138
  * field auto-following the dashboard's custom-topup config) — the
129
139
  * `allow-promo-code` attribute is only needed to force it on before you've
130
- * set one up. Not validated client-side an invalid code simply doesn't
131
- * grant a bonus when the top-up is processed. */
140
+ * set one up. Format-only client check (flags an obvious typo/paste
141
+ * mistake on blur, see wireUpPromoCode()) — whether the code is actually
142
+ * active/redeemable is only known server-side when a purchase is made. */
132
143
  private renderPromoCode;
133
144
  /** The current promo code input value (uppercased, trimmed), or undefined
134
145
  * when the field isn't shown/filled in. Read at click time — never re-render
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  CrediballReferralCardElement,
7
7
  CrediballTopUpButtonElement,
8
8
  CrediballUsageElement
9
- } from "./chunk-YP5JNDTD.js";
9
+ } from "./chunk-ELPD754U.js";
10
10
  export {
11
11
  CrediballBalanceElement,
12
12
  CrediballElement,
package/dist/register.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  CrediballReferralCardElement,
6
6
  CrediballTopUpButtonElement,
7
7
  CrediballUsageElement
8
- } from "./chunk-YP5JNDTD.js";
8
+ } from "./chunk-ELPD754U.js";
9
9
 
10
10
  // src/register.ts
11
11
  function define(name, ctor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/elements",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Framework-agnostic web components for showing Crediball credits inside your AI app.",
5
5
  "license": "MIT",
6
6
  "author": "Filippo Rezzadore <filipporezzadore@gmail.com>",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@crediball/core": "^0.9.0"
59
+ "@crediball/core": "^0.10.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "tsup": "^8.3.5",