@crediball/elements 0.1.0 → 0.3.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,90 @@ 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
- );
355
+ if (plan && this.emit("crediball-select-plan", plan)) {
356
+ void this.startSubscriptionCheckout(plan.id);
322
357
  }
323
358
  } else if (kind === "amount") {
324
359
  const amount = Number(btn.dataset.amount);
325
- this.dispatchEvent(
326
- new CustomEvent("crediball-topup", { detail: { amount }, bubbles: true, composed: true })
327
- );
360
+ if (this.emit("crediball-topup", { amount })) {
361
+ void this.startCheckout({ eurAmount: amount });
362
+ }
328
363
  }
329
364
  });
330
365
  });
366
+ if (showCustom) {
367
+ const input = this.shadow.querySelector(".custom-input");
368
+ const addBtn = this.shadow.querySelector(".custom-add");
369
+ const errEl = this.shadow.querySelector(".error");
370
+ const submit = () => {
371
+ const amount = Number(input?.value);
372
+ const sym = currencySymbolFor(currency);
373
+ if (!Number.isFinite(amount) || amount <= 0) {
374
+ return showError(errEl, "Enter a valid amount.");
375
+ }
376
+ if (custom?.minEur != null && amount < custom.minEur) {
377
+ return showError(errEl, `Minimum is ${sym}${custom.minEur}.`);
378
+ }
379
+ if (custom?.maxEur != null && amount > custom.maxEur) {
380
+ return showError(errEl, `Maximum is ${sym}${custom.maxEur}.`);
381
+ }
382
+ showError(errEl, null);
383
+ if (this.emit("crediball-topup", { amount })) {
384
+ void this.startCheckout({ eurAmount: amount });
385
+ }
386
+ };
387
+ addBtn?.addEventListener("click", submit);
388
+ input?.addEventListener("keydown", (e) => {
389
+ if (e.key === "Enter") submit();
390
+ });
391
+ }
392
+ }
393
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
394
+ emit(name, detail) {
395
+ const ev = new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable: true });
396
+ this.dispatchEvent(ev);
397
+ return !ev.defaultPrevented;
398
+ }
399
+ returnPath() {
400
+ return typeof window !== "undefined" ? window.location.pathname + window.location.search : void 0;
401
+ }
402
+ /** Start the built-in top-up Checkout and redirect. Errors are surfaced in the dialog. */
403
+ startCheckout(input) {
404
+ if (!this.client) return Promise.resolve();
405
+ return this.redirect(this.client.createCheckout({ ...input, returnPath: this.returnPath() }));
406
+ }
407
+ /** Start the built-in recurring subscription Checkout and redirect. */
408
+ startSubscriptionCheckout(planId) {
409
+ if (!this.client) return Promise.resolve();
410
+ return this.redirect(
411
+ this.client.createSubscriptionCheckout({ planId, returnPath: this.returnPath() })
412
+ );
413
+ }
414
+ async redirect(session) {
415
+ try {
416
+ const { url } = await session;
417
+ if (typeof window !== "undefined") window.location.assign(url);
418
+ } catch (err) {
419
+ const errEl = this.shadow.querySelector(".error");
420
+ showError(errEl, err instanceof Error ? err.message : "Checkout failed. Please try again.");
421
+ }
331
422
  }
332
423
  };
424
+ function showError(el, message) {
425
+ if (!el) return;
426
+ if (message) {
427
+ el.textContent = message;
428
+ el.hidden = false;
429
+ } else {
430
+ el.textContent = "";
431
+ el.hidden = true;
432
+ }
433
+ }
333
434
 
334
435
  export {
335
436
  CrediballElement,
@@ -122,6 +122,12 @@ var Crediball = (() => {
122
122
  this.costOf = (event) => {
123
123
  return this.store.get().costs[event]?.costCredits;
124
124
  };
125
+ this.createCheckout = (input) => {
126
+ return this.postCheckout("/public/topup/checkout", input);
127
+ };
128
+ this.createSubscriptionCheckout = (input) => {
129
+ return this.postCheckout("/public/subscriptions/checkout", input);
130
+ };
125
131
  this.refresh = async () => {
126
132
  const { publishableKey, userId } = this.config;
127
133
  const headers = { Authorization: `Bearer ${publishableKey}` };
@@ -182,6 +188,23 @@ var Crediball = (() => {
182
188
  this.apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
183
189
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_MS;
184
190
  }
191
+ async postCheckout(path, input) {
192
+ const { publishableKey, userId } = this.config;
193
+ const res = await fetch(`${this.apiUrl}${path}`, {
194
+ method: "POST",
195
+ headers: {
196
+ "Content-Type": "application/json",
197
+ Authorization: `Bearer ${publishableKey}`
198
+ },
199
+ body: JSON.stringify({ userId, ...input })
200
+ });
201
+ if (!res.ok) {
202
+ const body = await res.json().catch(() => null);
203
+ const message = body && typeof body === "object" && "error" in body && String(body.error) || `Checkout request failed with ${res.status}`;
204
+ throw new Error(message);
205
+ }
206
+ return res.json();
207
+ }
185
208
  pause() {
186
209
  this.stopPolling();
187
210
  this.disconnectSse();
@@ -445,6 +468,10 @@ var Crediball = (() => {
445
468
  return `${amount.toFixed(2)} ${currency}`;
446
469
  }
447
470
  }
471
+ var CURRENCY_SYMBOLS = { EUR: "\u20AC", USD: "$", GBP: "\xA3" };
472
+ function currencySymbolFor(currency) {
473
+ return CURRENCY_SYMBOLS[currency.toUpperCase()] ?? currency;
474
+ }
448
475
  var HTML_ESCAPES = {
449
476
  "&": "&amp;",
450
477
  "<": "&lt;",
@@ -498,6 +525,33 @@ var Crediball = (() => {
498
525
  border-radius: var(--crediball-radius, 9999px);
499
526
  }
500
527
  button.option .meta { opacity: 0.85; font-size: 14px; }
528
+ .custom-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
529
+ .custom-row .sym { font-size: 14px; color: var(--crediball-ink-muted, #7a7a7a); }
530
+ .custom-row input {
531
+ flex: 1;
532
+ min-width: 0;
533
+ appearance: none;
534
+ font: inherit;
535
+ font-size: 15px;
536
+ padding: 10px 14px;
537
+ border-radius: var(--crediball-radius, 9999px);
538
+ border: 1px solid var(--crediball-hairline, #e0e0e0);
539
+ background: var(--crediball-canvas, #ffffff);
540
+ color: var(--crediball-ink, #1d1d1f);
541
+ outline: none;
542
+ }
543
+ button.custom-add {
544
+ appearance: none;
545
+ border: none;
546
+ cursor: pointer;
547
+ background: var(--crediball-accent, #0066cc);
548
+ color: var(--crediball-on-accent, #ffffff);
549
+ font: inherit;
550
+ font-size: 15px;
551
+ padding: 10px 18px;
552
+ border-radius: var(--crediball-radius, 9999px);
553
+ }
554
+ p.error { margin: 8px 0 0; font-size: 13px; color: var(--crediball-accent, #0066cc); }
501
555
  button.close {
502
556
  width: 100%;
503
557
  margin-top: 12px;
@@ -542,6 +596,8 @@ var Crediball = (() => {
542
596
  const legacyAmounts = amountsAttr ? amountsAttr.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)) : [5, 10];
543
597
  const packages = pkgs?.packages ?? [];
544
598
  const plans = pkgs?.activeSubscription ? [] : pkgs?.subscriptionPlans ?? [];
599
+ const custom = pkgs?.customTopup;
600
+ const showCustom = Boolean(custom?.enabled);
545
601
  this.shadow.innerHTML = `
546
602
  <style>${STYLE}</style>
547
603
  <div class="overlay" part="overlay">
@@ -568,13 +624,19 @@ var Crediball = (() => {
568
624
  <span>${escapeHtml(p.name)}</span>
569
625
  <span class="meta">${formatMoney(p.price, currency)} \xB7 ${formatCredits(p.credits)} cr</span>
570
626
  </button>`
571
- ).join("") : legacyAmounts.map(
627
+ ).join("") : showCustom ? "" : legacyAmounts.map(
572
628
  (a) => `
573
629
  <button class="option" data-kind="amount" data-amount="${a}">
574
630
  <span>Add ${a}</span>
575
631
  </button>`
576
632
  ).join("")}
577
633
  </div>
634
+ ${showCustom ? `<div class="custom-row">
635
+ <span class="sym">${escapeHtml(currencySymbolFor(currency))}</span>
636
+ <input class="custom-input" inputmode="decimal" placeholder="${custom?.minEur ?? ""}" aria-label="Custom amount" />
637
+ <button class="custom-add" type="button">Add</button>
638
+ </div>` : ""}
639
+ <p class="error" hidden></p>
578
640
  <button class="close" part="close" type="button">Not now</button>
579
641
  </div>
580
642
  </div>
@@ -589,28 +651,90 @@ var Crediball = (() => {
589
651
  const kind = btn.dataset.kind;
590
652
  if (kind === "package") {
591
653
  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
- );
654
+ if (pkg && this.emit("crediball-select-package", pkg)) {
655
+ void this.startCheckout({ packageId: pkg.id });
596
656
  }
597
657
  } else if (kind === "plan") {
598
658
  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
- );
659
+ if (plan && this.emit("crediball-select-plan", plan)) {
660
+ void this.startSubscriptionCheckout(plan.id);
603
661
  }
604
662
  } else if (kind === "amount") {
605
663
  const amount = Number(btn.dataset.amount);
606
- this.dispatchEvent(
607
- new CustomEvent("crediball-topup", { detail: { amount }, bubbles: true, composed: true })
608
- );
664
+ if (this.emit("crediball-topup", { amount })) {
665
+ void this.startCheckout({ eurAmount: amount });
666
+ }
609
667
  }
610
668
  });
611
669
  });
670
+ if (showCustom) {
671
+ const input = this.shadow.querySelector(".custom-input");
672
+ const addBtn = this.shadow.querySelector(".custom-add");
673
+ const errEl = this.shadow.querySelector(".error");
674
+ const submit = () => {
675
+ const amount = Number(input?.value);
676
+ const sym = currencySymbolFor(currency);
677
+ if (!Number.isFinite(amount) || amount <= 0) {
678
+ return showError(errEl, "Enter a valid amount.");
679
+ }
680
+ if (custom?.minEur != null && amount < custom.minEur) {
681
+ return showError(errEl, `Minimum is ${sym}${custom.minEur}.`);
682
+ }
683
+ if (custom?.maxEur != null && amount > custom.maxEur) {
684
+ return showError(errEl, `Maximum is ${sym}${custom.maxEur}.`);
685
+ }
686
+ showError(errEl, null);
687
+ if (this.emit("crediball-topup", { amount })) {
688
+ void this.startCheckout({ eurAmount: amount });
689
+ }
690
+ };
691
+ addBtn?.addEventListener("click", submit);
692
+ input?.addEventListener("keydown", (e) => {
693
+ if (e.key === "Enter") submit();
694
+ });
695
+ }
696
+ }
697
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
698
+ emit(name, detail) {
699
+ const ev = new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable: true });
700
+ this.dispatchEvent(ev);
701
+ return !ev.defaultPrevented;
702
+ }
703
+ returnPath() {
704
+ return typeof window !== "undefined" ? window.location.pathname + window.location.search : void 0;
705
+ }
706
+ /** Start the built-in top-up Checkout and redirect. Errors are surfaced in the dialog. */
707
+ startCheckout(input) {
708
+ if (!this.client) return Promise.resolve();
709
+ return this.redirect(this.client.createCheckout({ ...input, returnPath: this.returnPath() }));
710
+ }
711
+ /** Start the built-in recurring subscription Checkout and redirect. */
712
+ startSubscriptionCheckout(planId) {
713
+ if (!this.client) return Promise.resolve();
714
+ return this.redirect(
715
+ this.client.createSubscriptionCheckout({ planId, returnPath: this.returnPath() })
716
+ );
717
+ }
718
+ async redirect(session) {
719
+ try {
720
+ const { url } = await session;
721
+ if (typeof window !== "undefined") window.location.assign(url);
722
+ } catch (err) {
723
+ const errEl = this.shadow.querySelector(".error");
724
+ showError(errEl, err instanceof Error ? err.message : "Checkout failed. Please try again.");
725
+ }
612
726
  }
613
727
  };
728
+ function showError(el, message) {
729
+ if (!el) return;
730
+ if (message) {
731
+ el.textContent = message;
732
+ el.hidden = false;
733
+ } else {
734
+ el.textContent = "";
735
+ el.hidden = true;
736
+ }
737
+ }
614
738
 
615
739
  // src/register.ts
616
740
  function define(name, ctor) {
package/dist/index.d.ts CHANGED
@@ -88,13 +88,17 @@ 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
+ * Everything pays out of the box: picking a package, entering a custom amount, or
96
+ * choosing a subscription plan starts a Crediball-hosted Stripe Checkout (using the
97
+ * publishable key) and redirects — the developer only has to connect payouts in the
98
+ * dashboard. Subscriptions bill recurringly and grant credits each period. Each
99
+ * choice first dispatches a bubbling, CANCELABLE event (`crediball-select-package` /
100
+ * `crediball-topup` / `crediball-select-plan`); call `event.preventDefault()` in a
101
+ * listener to suppress the built-in checkout and run your own flow instead.
98
102
  */
99
103
  declare class CrediballPaywallElement extends CrediballElement {
100
104
  private shadow;
@@ -103,6 +107,14 @@ declare class CrediballPaywallElement extends CrediballElement {
103
107
  open(): void;
104
108
  close(): void;
105
109
  protected render(): void;
110
+ /** Dispatch a cancelable option event; returns true when the host did NOT preventDefault. */
111
+ private emit;
112
+ private returnPath;
113
+ /** Start the built-in top-up Checkout and redirect. Errors are surfaced in the dialog. */
114
+ private startCheckout;
115
+ /** Start the built-in recurring subscription Checkout and redirect. */
116
+ private startSubscriptionCheckout;
117
+ private redirect;
106
118
  }
107
119
 
108
120
  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-AEVAHMRY.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-AEVAHMRY.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.3.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.2.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "tsup": "^8.3.5",