Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 103 additions & 12 deletions assets/js/hubsubscription.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use strict";

const BILLING_PORTAL_SESSION_URL = LEGACY_STORE_URL + '/hub/billing-portal-session';
const BILLING_SESSION_URL = API_BASE_URL + '/billing/session';
const BILLING_CUSTOMER_URL = API_BASE_URL + '/billing/customers/by-hub-id';
const CUSTOM_BILLING_URL = LEGACY_STORE_URL + '/hub/custom-billing';
const GENERATE_PAY_LINK_URL = LEGACY_STORE_URL + '/hub/generate-pay-link';
const MANAGE_SUBSCRIPTION_URL = LEGACY_STORE_URL + '/hub/manage-subscription';
Expand All @@ -23,15 +24,26 @@ class HubSubscription {
this._subscriptionData.oldLicense = null;
}
}
this._subscriptionData.hubId = this._subscriptionData.hubId ?? searchParams.get('hub_id');
let returnUrl = fragmentParams.get('returnUrl') ?? searchParams.get('return_url');
this._subscriptionData.hubId = this._subscriptionData.hubId ?? fragmentParams.get('hub_id') ?? searchParams.get('hub_id');
// Record whether the returnUrl arrived in the URL fragment or the query string as `fragmentOrQuery`
// ('#' or '?'), so the billing API knows how to reconstruct the redirect later. If both carry it, the
// fragment wins.
let returnUrlInFragment = fragmentParams.get('returnUrl');
let returnUrl = returnUrlInFragment ?? searchParams.get('return_url');
if (returnUrl) {
this._subscriptionData.returnUrl = returnUrl;
this._subscriptionData.fragmentOrQuery = returnUrlInFragment ? '#' : '?';
}
this._subscriptionData.session = searchParams.get('session');
if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) {
if (this._subscriptionData.session) {
// We returned from the confirmation link (/hub/billing?session=<id>): resolve the
// verified billing session and continue into the existing subscription flow.
this._subscriptionData.state = 'LOADING';
this.loadSubscription();
this.loadBillingSession();
} else if (this._subscriptionData.hubId && this._subscriptionData.hubId.length > 0 && this._subscriptionData.returnUrl && this._subscriptionData.returnUrl.length > 0) {
// Opened from the Hub without a verified session yet: ask the customer to request a
// confirmation link before we can manage their subscription.
this._subscriptionData.state = 'CREATE_SESSION';
}
this._paddle = $.ajax({
url: 'https://cdn.paddle.com/paddle/paddle.js',
Expand Down Expand Up @@ -94,6 +106,79 @@ class HubSubscription {
this._subscriptionData.inProgress = false;
}

loadBillingSession() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
$.ajax({
url: BILLING_SESSION_URL + '/' + encodeURIComponent(this._subscriptionData.session),
type: 'GET'
}).done(data => {
this.onLoadBillingSessionSucceeded(data);
}).fail(xhr => {
this.onLoadBillingSessionFailed(xhr.status, xhr.responseJSON?.message || 'Loading billing session failed.');
});
}

onLoadBillingSessionSucceeded(data) {
this._subscriptionData.hubId = data.hubId;
this._subscriptionData.email = data.email;
this._subscriptionData.returnUrl = data.returnUrl;
this._subscriptionData.fragmentOrQuery = data.fragmentOrQuery;
this._subscriptionData.errorMessage = '';
// The session is verified; hand off to the existing subscription flow (store + Paddle).
this.loadSubscription();
}

onLoadBillingSessionFailed(status, error) {
if (status == 404) {
this._subscriptionData.state = 'LINK_EXPIRED';
this._subscriptionData.errorMessage = '';
} else {
this._subscriptionData.state = 'CREATE_SESSION';
this._subscriptionData.errorMessage = error;
}
this._subscriptionData.inProgress = false;
}

lookupCustomer() {
this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
// First challenge (from /billing/customers/challenge) gates this lookup: it tells us whether
// the Hub is already linked to a customer before we ask for a confirmation link.
$.ajax({
url: BILLING_CUSTOMER_URL + '/' + encodeURIComponent(this._subscriptionData.hubId) + '?captcha=' + encodeURIComponent(this._subscriptionData.captcha),
type: 'GET'
}).done(data => {
this.onLookupCustomerSucceeded(data);
}).fail(xhr => {
this.onLookupCustomerFailed(xhr.status, xhr.responseJSON?.message || 'Looking up your subscription failed.');
});
}

onLookupCustomerSucceeded(data) {
// The Hub is already linked to a customer: the API returns their redacted email so we can
// show where the confirmation link will be sent without revealing the full address.
this._subscriptionData.redactedEmail = data.email;
this._subscriptionData.needsEmail = false;
this._subscriptionData.lookupDone = true;
this._subscriptionData.errorMessage = '';
this._subscriptionData.inProgress = false;
}

onLookupCustomerFailed(status, error) {
this._subscriptionData.inProgress = false;
if (status == 404) {
// The Hub is not linked to a customer yet: ask for the purchase email so the session
// request can be created for that address.
this._subscriptionData.needsEmail = true;
this._subscriptionData.redactedEmail = null;
this._subscriptionData.lookupDone = true;
this._subscriptionData.errorMessage = '';
} else {
this._subscriptionData.errorMessage = error;
}
}

createSession() {
if (!$(this._form)[0].checkValidity()) {
$(this._form).find(':input').addClass('show-invalid');
Expand All @@ -103,18 +188,24 @@ class HubSubscription {

this._subscriptionData.inProgress = true;
this._subscriptionData.errorMessage = '';
let body = {
hubId: this._subscriptionData.hubId,
returnUrl: this._subscriptionData.returnUrl,
fragmentOrQuery: this._subscriptionData.fragmentOrQuery,
captcha: this._subscriptionData.captcha
};
if (this._subscriptionData.email) {
body.email = this._subscriptionData.email;
}
$.ajax({
url: BILLING_PORTAL_SESSION_URL,
url: BILLING_SESSION_URL,
type: 'POST',
data: {
captcha: this._subscriptionData.captcha,
hub_id: this._subscriptionData.hubId,
return_url: this._subscriptionData.returnUrl
}
contentType: 'application/json',
data: JSON.stringify(body)
}).done(_ => {
this.onCreateSessionSucceeded();
}).fail(xhr => {
this.onCreateSessionFailed(xhr.responseJSON?.message || 'Creating billing portal session failed.');
this.onCreateSessionFailed(xhr.responseJSON?.message || 'Requesting confirmation link failed.');
});
}

Expand Down
13 changes: 11 additions & 2 deletions i18n/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -503,11 +503,20 @@
translation: "Bitte warten, während wir deine Abonnement-Informationen laden."

- id: hub_billing_createsession_description
translation: "Um auf deine Abonnement-Informationen zuzugreifen, wird ein Bestätigungslink an die E-Mail-Adresse gesendet, die du für den Kauf verwendet hast."
translation: "Hub-Instanz-Details werden abgerufen …"
- id: hub_billing_createsession_success_description
translation: "Bitte überprüfe deine E-Mails auf den Link zur Verwaltung deines Hub-Abonnements."
- id: hub_billing_createsession_submit
translation: "Zugang anfordern"
translation: "Link senden"
- id: hub_billing_createsession_email_description
translation: "Bereit zum Abonnieren? Um die Abrechnung für diesen Hub einzurichten, gib deine E-Mail-Adresse ein und wir senden dir einen Link, um zur Zahlung zu gelangen."
- id: hub_billing_createsession_email_placeholder
translation: "Deine E-Mail-Adresse"
- id: hub_billing_createsession_found_description
translation: "Wir haben ein bestehendes Abonnement für diesen Hub gefunden. Wir senden den Bestätigungslink an:"

- id: hub_billing_linkexpired_description
translation: "Dieser Link ist nicht mehr gültig. Bestätigungslinks können nur einmal verwendet werden und verfallen nach einer Weile. Bitte fordere über deine Hub-Instanz einen neuen Link an."

- id: hub_billing_manage_status_title
translation: "Status"
Expand Down
13 changes: 11 additions & 2 deletions i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -503,11 +503,20 @@
translation: "Please wait while we're loading your subscription information."

- id: hub_billing_createsession_description
translation: "To access your subscription information, a confirmation link will be sent to the email you used to make your purchase."
translation: "Looking up Hub instance details…"
- id: hub_billing_createsession_success_description
translation: "Please check your email for the link to manage your Hub subscription."
- id: hub_billing_createsession_submit
translation: "Request Access"
translation: "Send Link"
- id: hub_billing_createsession_email_description
translation: "Ready to subscribe? To set up billing for this Hub, enter your email address and we'll send you a link to continue to checkout."
- id: hub_billing_createsession_email_placeholder
translation: "Your email address"
- id: hub_billing_createsession_found_description
translation: "We found an existing subscription for this Hub. We'll send the confirmation link to:"

- id: hub_billing_linkexpired_description
translation: "This link is no longer valid. Confirmation links can only be used once and expire after a while. Please request a new link from your Hub instance."

- id: hub_billing_manage_status_title
translation: "Status"
Expand Down
76 changes: 64 additions & 12 deletions layouts/hub-billing/single.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{{ end }}
{{ define "main" }}
<div class="container pt-12 pb-24">
<form x-data="{subscriptionData: {state: 'MISSING_PARAMS', captcha: null, hubId: null, returnUrl: null, session: null, oldLicense: null, customBilling: null, billingInterval: 'yearly', savingsPercent: null, errorMessage: '', inProgress: false, restartModal: {open: false, nextPayment: null}, changeSeatsModal: {open: false, confirmation: false, immediatePayment: null}, token: null, details: null, quantity: 5, email: '', needsTokenRefresh: false, shouldTransferToHub: false}, acceptTerms: false, hubSubscription: null, captchaState: null}" x-init="hubSubscription = new HubSubscription($refs.form, subscriptionData, new URLSearchParams(location.search))" x-ref="form" @submit.prevent="hubSubscription.createSession(); $refs.captcha.reset()">
<form x-data="{subscriptionData: {state: 'MISSING_PARAMS', captcha: null, hubId: null, returnUrl: null, session: null, oldLicense: null, customBilling: null, billingInterval: 'yearly', savingsPercent: null, errorMessage: '', inProgress: false, restartModal: {open: false, nextPayment: null}, changeSeatsModal: {open: false, confirmation: false, immediatePayment: null}, token: null, details: null, quantity: 5, email: '', needsEmail: false, lookupDone: false, redactedEmail: null, needsTokenRefresh: false, shouldTransferToHub: false}, acceptTerms: false, hubSubscription: null, captchaState: null}" x-init="hubSubscription = new HubSubscription($refs.form, subscriptionData, new URLSearchParams(location.search))" x-ref="form" @submit.prevent="hubSubscription.createSession(); $refs.captcha.reset()">
<template x-if="subscriptionData.state == 'MISSING_PARAMS'">
<div class="text-center max-w-xl mx-auto">
<h3 class="font-headline text-xl md:text-2xl leading-relaxed mb-4">
Expand Down Expand Up @@ -34,17 +34,57 @@ <h3 class="font-headline text-xl md:text-2xl leading-relaxed mb-4">
<h3 class="font-headline text-xl md:text-2xl leading-relaxed mb-4">
{{ i18n "hub_billing_generic_title" . }}
</h3>
<p class="font-p mb-4">
<i class="fa-solid fa-sign-in"></i>
{{ i18n "hub_billing_createsession_description" . }}
</p>
<button :disabled="subscriptionData.inProgress || captchaState == 'verifying'" type="submit" class="btn btn-primary w-full md:w-64">
<i :class="{'fa-paper-plane': !subscriptionData.inProgress, 'fa-spinner fa-spin': subscriptionData.inProgress}" class="fa-solid" aria-hidden="true"></i>
{{ i18n "hub_billing_createsession_submit" . }}
</button>
{{ $challengeUrl := printf "%s/connect/store/challenge" .Site.Params.apiBaseUrl }}
{{ partial "captcha.html" (dict "challengeUrl" $challengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState") }}
<p :class="{'hidden': !subscriptionData.errorMessage}" class="text-sm text-red-600 mt-2" x-text="subscriptionData.errorMessage"></p>
<template x-if="!subscriptionData.lookupDone">
<div>
<p class="font-p mb-4">
<i class="fa-solid fa-spinner fa-spin"></i>
{{ i18n "hub_billing_createsession_description" . }}
</p>
{{ $lookupChallengeUrl := printf "%s/billing/customers/challenge" .Site.Params.apiBaseUrl }}
{{/* The challenge is solved automatically on load (auto=onload), so the widget needs no
interaction; hide it since the spinner above already signals progress. altcha solves
the proof-of-work in a worker regardless of visibility. */}}
<div class="hidden">
{{ partial "captcha.html" (dict "challengeUrl" $lookupChallengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState" "ref" "lookupCaptcha" "auto" "onload" "onVerified" "hubSubscription.lookupCustomer()") }}
</div>
<p :class="{'hidden': !subscriptionData.errorMessage}" class="text-sm text-red-600 mt-2" x-text="subscriptionData.errorMessage"></p>
</div>
</template>
<template x-if="subscriptionData.lookupDone">
<div>
<template x-if="!subscriptionData.needsEmail">
<div>
<p class="font-p mb-4">
<i class="fa-solid fa-envelope"></i>
{{ i18n "hub_billing_createsession_found_description" . }}
<span x-text="subscriptionData.redactedEmail" class="font-medium whitespace-nowrap"></span>
</p>
<button :disabled="subscriptionData.inProgress || captchaState == 'verifying'" type="submit" class="btn btn-primary w-full md:w-64">
<i :class="{'fa-paper-plane': !subscriptionData.inProgress, 'fa-spinner fa-spin': subscriptionData.inProgress}" class="fa-solid" aria-hidden="true"></i>
{{ i18n "hub_billing_createsession_submit" . }}
</button>
</div>
</template>
<template x-if="subscriptionData.needsEmail">
<div>
<p class="font-p mb-4">
<i class="fa-solid fa-envelope"></i>
{{ i18n "hub_billing_createsession_email_description" . }}
</p>
<div class="flex justify-center items-center rounded-sm bg-gray-300 mb-4">
<input x-model="subscriptionData.email" :required="subscriptionData.needsEmail" type="email" class="grow rounded-l border-gray-300 focus:ring-0 focus:border-secondary" placeholder="{{ i18n "hub_billing_createsession_email_placeholder" . }}"/>
<button :disabled="subscriptionData.inProgress || captchaState == 'verifying'" type="submit" class="shrink-0 flex items-center gap-1 btn btn-primary rounded-l-none px-4">
<i :class="{'fa-paper-plane': !subscriptionData.inProgress, 'fa-spinner fa-spin': subscriptionData.inProgress}" class="fa-solid" aria-hidden="true"></i>
{{ i18n "hub_billing_createsession_submit" . }}
</button>
</div>
</div>
</template>
{{ $sessionChallengeUrl := printf "%s/billing/session/challenge" .Site.Params.apiBaseUrl }}
{{ partial "captcha.html" (dict "challengeUrl" $sessionChallengeUrl "captchaPayload" "subscriptionData.captcha" "captchaState" "captchaState") }}
<p :class="{'hidden': !subscriptionData.errorMessage}" class="text-sm text-red-600 mt-2" x-text="subscriptionData.errorMessage"></p>
</div>
</template>
</div>
</template>

Expand All @@ -60,6 +100,18 @@ <h3 class="font-headline text-xl md:text-2xl leading-relaxed mb-4">
</div>
</template>

<template x-if="subscriptionData.state == 'LINK_EXPIRED'">
<div class="text-center max-w-xl mx-auto">
<h3 class="font-headline text-xl md:text-2xl leading-relaxed mb-4">
{{ i18n "hub_billing_generic_title" . }}
</h3>
<p class="font-p mb-4">
<i class="fa-solid fa-link-slash"></i>
{{ i18n "hub_billing_linkexpired_description" . }}
</p>
</div>
</template>

<template x-if="subscriptionData.state == 'EXISTING_CUSTOMER'">
<div>
<header class="mb-6">
Expand Down