@crediball/elements 0.1.0 → 0.2.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.
@@ -164,6 +164,10 @@ function formatMoney(amount, currency) {
164
164
  return `${amount.toFixed(2)} ${currency}`;
165
165
  }
166
166
  }
167
+ var CURRENCY_SYMBOLS = { EUR: "\u20AC", USD: "$", GBP: "\xA3" };
168
+ function currencySymbolFor(currency) {
169
+ return CURRENCY_SYMBOLS[currency.toUpperCase()] ?? currency;
170
+ }
167
171
  var HTML_ESCAPES = {
168
172
  "&": "&",
169
173
  "<": "&lt;",
@@ -217,6 +221,33 @@ var STYLE = `
217
221
  border-radius: var(--crediball-radius, 9999px);
218
222
  }
219
223
  button.option .meta { opacity: 0.85; font-size: 14px; }
224
+ .custom-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
225
+ .custom-row .sym { font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
226
+ .custom-row input {
227
+ flex: 1;
228
+ min-width: 0;
229
+ appearance: none;
230
+ font: inherit;
231
+ font-size: 15px;
232
+ padding: 10px 14px;
233
+ border-radius: var(--crediball-radius, 9999px);
234
+ border: 1px solid var(--crediball-hairline, #e0e0e0);
235
+ background: var(--crediball-canvas, #ffffff);
236
+ color: var(--crediball-ink, #1d1d1f);
237
+ outline: none;
238
+ }
239
+ button.custom-add {
240
+ appearance: none;
241
+ border: none;
242
+ cursor: pointer;
243
+ background: var(--crediball-accent, #0066cc);
244
+ color: var(--crediball-on-accent, #ffffff);
245
+ font: inherit;
246
+ font-size: 15px;
247
+ padding: 10px 18px;
248
+ border-radius: var(--crediball-radius, 9999px);
249
+ }
250
+ p.error { margin: 8px 0 0; font-size: 13px; color: var(--crediball-accent, #0066cc); }
220
251
  button.close {
221
252
  width: 100%;
222
253
  margin-top: 12px;
@@ -261,6 +292,8 @@ var CrediballPaywallElement = class extends CrediballElement {
261
292
  const legacyAmounts = amountsAttr ? amountsAttr.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) : [5, 10];
262
293
  const packages = pkgs?.packages ?? [];
263
294
  const plans = pkgs?.activeSubscription ? [] : pkgs?.subscriptionPlans ?? [];
295
+ const custom = pkgs?.customTopup;
296
+ const showCustom = Boolean(custom?.enabled);
264
297
  this.shadow.innerHTML = `
265
298
  <style>${STYLE}</style>
266
299
  <div class="overlay" part="overlay">
@@ -287,13 +320,19 @@ var CrediballPaywallElement = class extends CrediballElement {
287
320
  <span>${escapeHtml(p.name)}</span>
288
321
  <span class="meta">${formatMoney(p.price, currency)} \xB7 ${formatCredits(p.credits)} cr</span>
289
322
  </button>`
290
- ).join("") : legacyAmounts.map(
323
+ ).join("") : showCustom ? "" : legacyAmounts.map(
291
324
  (a) => `
292
325
  <button class="option" data-kind="amount" data-amount="${a}">
293
326
  <span>Add ${a}</span>
294
327
  </button>`
295
328
  ).join("")}
296
329
  </div>
330
+ ${showCustom ? `<div class="custom-row">
331
+ <span class="sym">${escapeHtml(currencySymbolFor(currency))}</span>
332
+ <input class="custom-input" inputmode="decimal" placeholder="${custom?.minEur ?? ""}" aria-label="Custom amount" />
333
+ <button class="custom-add" type="button">Add</button>
334
+ </div>
335
+ <p class="error" hidden></p>` : ""}
297
336
  <button class="close" part="close" type="button">Not now</button>
298
337
  </div>
299
338
  </div>
@@ -308,28 +347,76 @@ var CrediballPaywallElement = class extends CrediballElement {
308
347
  const kind = btn.dataset.kind;
309
348
  if (kind === "package") {
310
349
  const pkg = packages.find((p) => p.id === btn.dataset.id);
311
- if (pkg) {
312
- this.dispatchEvent(
313
- new CustomEvent("crediball-select-package", { detail: pkg, bubbles: true, composed: true })
314
- );
350
+ if (pkg && this.emit("crediball-select-package", pkg)) {
351
+ void this.startCheckout({ packageId: pkg.id });
315
352
  }
316
353
  } else if (kind === "plan") {
317
354
  const plan = plans.find((p) => p.id === btn.dataset.id);
318
- if (plan) {
319
- this.dispatchEvent(
320
- new CustomEvent("crediball-select-plan", { detail: plan, bubbles: true, composed: true })
321
- );
322
- }
355
+ if (plan) this.emit("crediball-select-plan", plan);
323
356
  } else if (kind === "amount") {
324
357
  const amount = Number(btn.dataset.amount);
325
- this.dispatchEvent(
326
- new CustomEvent("crediball-topup", { detail: { amount }, bubbles: true, composed: true })
327
- );
358
+ if (this.emit("crediball-topup", { amount })) {
359
+ void this.startCheckout({ eurAmount: amount });
360
+ }
328
361
  }
329
362
  });
330
363
  });
364
+ if (showCustom) {
365
+ const input = this.shadow.querySelector(".custom-input");
366
+ const addBtn = this.shadow.querySelector(".custom-add");
367
+ const errEl = this.shadow.querySelector(".error");
368
+ const submit = () => {
369
+ const amount = Number(input?.value);
370
+ const sym = currencySymbolFor(currency);
371
+ if (!Number.isFinite(amount) || amount <= 0) {
372
+ return showError(errEl, "Enter a valid amount.");
373
+ }
374
+ if (custom?.minEur != null && amount < custom.minEur) {
375
+ return showError(errEl, `Minimum is ${sym}${custom.minEur}.`);
376
+ }
377
+ if (custom?.maxEur != null && amount > custom.maxEur) {
378
+ return showError(errEl, `Maximum is ${sym}${custom.maxEur}.`);
379
+ }
380
+ showError(errEl, null);
381
+ if (this.emit("crediball-topup", { amount })) {
382
+ void this.startCheckout({ eurAmount: amount });
383
+ }
384
+ };
385
+ addBtn?.addEventListener("click", submit);
386
+ input?.addEventListener("keydown", (e) => {
387
+ if (e.key === "Enter") submit();
388
+ });
389
+ }
390
+ }
391
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
392
+ emit(name, detail) {
393
+ const ev = new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable: true });
394
+ this.dispatchEvent(ev);
395
+ return !ev.defaultPrevented;
396
+ }
397
+ /** Start the built-in Stripe Checkout and redirect. Errors are surfaced in the dialog. */
398
+ async startCheckout(input) {
399
+ if (!this.client) return;
400
+ try {
401
+ const returnPath = typeof window !== "undefined" ? window.location.pathname + window.location.search : void 0;
402
+ const { url } = await this.client.createCheckout({ ...input, returnPath });
403
+ if (typeof window !== "undefined") window.location.assign(url);
404
+ } catch (err) {
405
+ const errEl = this.shadow.querySelector(".error");
406
+ showError(errEl, err instanceof Error ? err.message : "Checkout failed. Please try again.");
407
+ }
331
408
  }
332
409
  };
410
+ function showError(el, message) {
411
+ if (!el) return;
412
+ if (message) {
413
+ el.textContent = message;
414
+ el.hidden = false;
415
+ } else {
416
+ el.textContent = "";
417
+ el.hidden = true;
418
+ }
419
+ }
333
420
 
334
421
  export {
335
422
  CrediballElement,
@@ -122,6 +122,23 @@ var Crediball = (() => {
122
122
  this.costOf = (event) => {
123
123
  return this.store.get().costs[event]?.costCredits;
124
124
  };
125
+ this.createCheckout = async (input) => {
126
+ const { publishableKey, userId } = this.config;
127
+ const res = await fetch(`${this.apiUrl}/public/topup/checkout`, {
128
+ method: "POST",
129
+ headers: {
130
+ "Content-Type": "application/json",
131
+ Authorization: `Bearer ${publishableKey}`
132
+ },
133
+ body: JSON.stringify({ userId, ...input })
134
+ });
135
+ if (!res.ok) {
136
+ const body = await res.json().catch(() => null);
137
+ const message = body && typeof body === "object" && "error" in body && String(body.error) || `Checkout request failed with ${res.status}`;
138
+ throw new Error(message);
139
+ }
140
+ return res.json();
141
+ };
125
142
  this.refresh = async () => {
126
143
  const { publishableKey, userId } = this.config;
127
144
  const headers = { Authorization: `Bearer ${publishableKey}` };
@@ -445,6 +462,10 @@ var Crediball = (() => {
445
462
  return `${amount.toFixed(2)} ${currency}`;
446
463
  }
447
464
  }
465
+ var CURRENCY_SYMBOLS = { EUR: "\u20AC", USD: "$", GBP: "\xA3" };
466
+ function currencySymbolFor(currency) {
467
+ return CURRENCY_SYMBOLS[currency.toUpperCase()] ?? currency;
468
+ }
448
469
  var HTML_ESCAPES = {
449
470
  "&": "&amp;",
450
471
  "<": "&lt;",
@@ -498,6 +519,33 @@ var Crediball = (() => {
498
519
  border-radius: var(--crediball-radius, 9999px);
499
520
  }
500
521
  button.option .meta { opacity: 0.85; font-size: 14px; }
522
+ .custom-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
523
+ .custom-row .sym { font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
524
+ .custom-row input {
525
+ flex: 1;
526
+ min-width: 0;
527
+ appearance: none;
528
+ font: inherit;
529
+ font-size: 15px;
530
+ padding: 10px 14px;
531
+ border-radius: var(--crediball-radius, 9999px);
532
+ border: 1px solid var(--crediball-hairline, #e0e0e0);
533
+ background: var(--crediball-canvas, #ffffff);
534
+ color: var(--crediball-ink, #1d1d1f);
535
+ outline: none;
536
+ }
537
+ button.custom-add {
538
+ appearance: none;
539
+ border: none;
540
+ cursor: pointer;
541
+ background: var(--crediball-accent, #0066cc);
542
+ color: var(--crediball-on-accent, #ffffff);
543
+ font: inherit;
544
+ font-size: 15px;
545
+ padding: 10px 18px;
546
+ border-radius: var(--crediball-radius, 9999px);
547
+ }
548
+ p.error { margin: 8px 0 0; font-size: 13px; color: var(--crediball-accent, #0066cc); }
501
549
  button.close {
502
550
  width: 100%;
503
551
  margin-top: 12px;
@@ -542,6 +590,8 @@ var Crediball = (() => {
542
590
  const legacyAmounts = amountsAttr ? amountsAttr.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) : [5, 10];
543
591
  const packages = pkgs?.packages ?? [];
544
592
  const plans = pkgs?.activeSubscription ? [] : pkgs?.subscriptionPlans ?? [];
593
+ const custom = pkgs?.customTopup;
594
+ const showCustom = Boolean(custom?.enabled);
545
595
  this.shadow.innerHTML = `
546
596
  <style>${STYLE}</style>
547
597
  <div class="overlay" part="overlay">
@@ -568,13 +618,19 @@ var Crediball = (() => {
568
618
  <span>${escapeHtml(p.name)}</span>
569
619
  <span class="meta">${formatMoney(p.price, currency)} \xB7 ${formatCredits(p.credits)} cr</span>
570
620
  </button>`
571
- ).join("") : legacyAmounts.map(
621
+ ).join("") : showCustom ? "" : legacyAmounts.map(
572
622
  (a) => `
573
623
  <button class="option" data-kind="amount" data-amount="${a}">
574
624
  <span>Add ${a}</span>
575
625
  </button>`
576
626
  ).join("")}
577
627
  </div>
628
+ ${showCustom ? `<div class="custom-row">
629
+ <span class="sym">${escapeHtml(currencySymbolFor(currency))}</span>
630
+ <input class="custom-input" inputmode="decimal" placeholder="${custom?.minEur ?? ""}" aria-label="Custom amount" />
631
+ <button class="custom-add" type="button">Add</button>
632
+ </div>
633
+ <p class="error" hidden></p>` : ""}
578
634
  <button class="close" part="close" type="button">Not now</button>
579
635
  </div>
580
636
  </div>
@@ -589,28 +645,76 @@ var Crediball = (() => {
589
645
  const kind = btn.dataset.kind;
590
646
  if (kind === "package") {
591
647
  const pkg = packages.find((p) => p.id === btn.dataset.id);
592
- if (pkg) {
593
- this.dispatchEvent(
594
- new CustomEvent("crediball-select-package", { detail: pkg, bubbles: true, composed: true })
595
- );
648
+ if (pkg && this.emit("crediball-select-package", pkg)) {
649
+ void this.startCheckout({ packageId: pkg.id });
596
650
  }
597
651
  } else if (kind === "plan") {
598
652
  const plan = plans.find((p) => p.id === btn.dataset.id);
599
- if (plan) {
600
- this.dispatchEvent(
601
- new CustomEvent("crediball-select-plan", { detail: plan, bubbles: true, composed: true })
602
- );
603
- }
653
+ if (plan) this.emit("crediball-select-plan", plan);
604
654
  } else if (kind === "amount") {
605
655
  const amount = Number(btn.dataset.amount);
606
- this.dispatchEvent(
607
- new CustomEvent("crediball-topup", { detail: { amount }, bubbles: true, composed: true })
608
- );
656
+ if (this.emit("crediball-topup", { amount })) {
657
+ void this.startCheckout({ eurAmount: amount });
658
+ }
609
659
  }
610
660
  });
611
661
  });
662
+ if (showCustom) {
663
+ const input = this.shadow.querySelector(".custom-input");
664
+ const addBtn = this.shadow.querySelector(".custom-add");
665
+ const errEl = this.shadow.querySelector(".error");
666
+ const submit = () => {
667
+ const amount = Number(input?.value);
668
+ const sym = currencySymbolFor(currency);
669
+ if (!Number.isFinite(amount) || amount <= 0) {
670
+ return showError(errEl, "Enter a valid amount.");
671
+ }
672
+ if (custom?.minEur != null && amount < custom.minEur) {
673
+ return showError(errEl, `Minimum is ${sym}${custom.minEur}.`);
674
+ }
675
+ if (custom?.maxEur != null && amount > custom.maxEur) {
676
+ return showError(errEl, `Maximum is ${sym}${custom.maxEur}.`);
677
+ }
678
+ showError(errEl, null);
679
+ if (this.emit("crediball-topup", { amount })) {
680
+ void this.startCheckout({ eurAmount: amount });
681
+ }
682
+ };
683
+ addBtn?.addEventListener("click", submit);
684
+ input?.addEventListener("keydown", (e) => {
685
+ if (e.key === "Enter") submit();
686
+ });
687
+ }
688
+ }
689
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
690
+ emit(name, detail) {
691
+ const ev = new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable: true });
692
+ this.dispatchEvent(ev);
693
+ return !ev.defaultPrevented;
694
+ }
695
+ /** Start the built-in Stripe Checkout and redirect. Errors are surfaced in the dialog. */
696
+ async startCheckout(input) {
697
+ if (!this.client) return;
698
+ try {
699
+ const returnPath = typeof window !== "undefined" ? window.location.pathname + window.location.search : void 0;
700
+ const { url } = await this.client.createCheckout({ ...input, returnPath });
701
+ if (typeof window !== "undefined") window.location.assign(url);
702
+ } catch (err) {
703
+ const errEl = this.shadow.querySelector(".error");
704
+ showError(errEl, err instanceof Error ? err.message : "Checkout failed. Please try again.");
705
+ }
612
706
  }
613
707
  };
708
+ function showError(el, message) {
709
+ if (!el) return;
710
+ if (message) {
711
+ el.textContent = message;
712
+ el.hidden = false;
713
+ } else {
714
+ el.textContent = "";
715
+ el.hidden = true;
716
+ }
717
+ }
614
718
 
615
719
  // src/register.ts
616
720
  function define(name, ctor) {
package/dist/index.d.ts CHANGED
@@ -88,13 +88,20 @@ declare class CrediballLowCreditWarningElement extends CrediballElement {
88
88
  /**
89
89
  * <crediball-paywall publishable-key="cb_pub_..." user-id="u123" title="..." description="..."></crediball-paywall>
90
90
  *
91
- * A themeable top-up dialog driven by the live packages/subscription-plans
92
- * catalog. Call `.open()` / `.close()`, or toggle the `open` attribute.
93
- * Purchases are never made client-side (the publishable key can't authorize
94
- * money movement) — selecting an option dispatches a bubbling event
95
- * (`crediball-select-package`, `crediball-select-plan`, or `crediball-topup`
96
- * for the legacy `amounts` fallback) that the host page listens for and
97
- * fulfils server-side, then calls `.close()`.
91
+ * A themeable top-up dialog driven by the live packages/custom-topup/
92
+ * subscription-plans catalog. Call `.open()` / `.close()`, or toggle the `open`
93
+ * attribute.
94
+ *
95
+ * Top-ups pay out of the box: picking a package or entering a custom amount
96
+ * starts a Crediball-hosted Stripe Checkout (using the publishable key) and
97
+ * redirects the developer only has to connect payouts in the dashboard. Each
98
+ * choice still first dispatches a bubbling, CANCELABLE event
99
+ * (`crediball-select-package` / `crediball-topup`); call `event.preventDefault()`
100
+ * in a listener to suppress the built-in checkout and run your own flow instead.
101
+ *
102
+ * Subscription plans have no built-in checkout (they need recurring billing your
103
+ * backend sets up): picking one only dispatches `crediball-select-plan` for the
104
+ * host to fulfil server-side.
98
105
  */
99
106
  declare class CrediballPaywallElement extends CrediballElement {
100
107
  private shadow;
@@ -103,6 +110,10 @@ declare class CrediballPaywallElement extends CrediballElement {
103
110
  open(): void;
104
111
  close(): void;
105
112
  protected render(): void;
113
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
114
+ private emit;
115
+ /** Start the built-in Stripe Checkout and redirect. Errors are surfaced in the dialog. */
116
+ private startCheckout;
106
117
  }
107
118
 
108
119
  export { CrediballBalanceElement, CrediballElement, CrediballLowCreditWarningElement, CrediballPaywallElement, CrediballTopUpButtonElement, CrediballUsageElement };
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  CrediballPaywallElement,
6
6
  CrediballTopUpButtonElement,
7
7
  CrediballUsageElement
8
- } from "./chunk-RBGPXBOH.js";
8
+ } from "./chunk-VW5SGSLN.js";
9
9
  export {
10
10
  CrediballBalanceElement,
11
11
  CrediballElement,
package/dist/register.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  CrediballPaywallElement,
5
5
  CrediballTopUpButtonElement,
6
6
  CrediballUsageElement
7
- } from "./chunk-RBGPXBOH.js";
7
+ } from "./chunk-VW5SGSLN.js";
8
8
 
9
9
  // src/register.ts
10
10
  function define(name, ctor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/elements",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.1.0"
59
+ "@crediball/core": "^0.1.1"
60
60
  },
61
61
  "devDependencies": {
62
62
  "tsup": "^8.3.5",