diff --git a/assets/js/const.template.js b/assets/js/const.template.js index f845d1351..32725b6df 100644 --- a/assets/js/const.template.js +++ b/assets/js/const.template.js @@ -13,6 +13,8 @@ const PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID = {{ .Site.Params.paddleHubManagedMonth const PADDLE_PRICES_URL = '{{ .Site.Params.paddlePricesUrl }}'; const PADDLE_DESKTOP_SALE_PRICE_ID = '{{ .Site.Params.paddleDesktopSalePriceId }}'; const PADDLE_ANDROID_SALE_PRICE_ID = '{{ .Site.Params.paddleAndroidSalePriceId }}'; +const ESPOCRM_HUB_SELF_HOSTED_PRODUCT_ID = '{{ .Site.Params.espocrmHubSelfHostedProductId }}'; +const ESPOCRM_HUB_MANAGED_PRODUCT_ID = '{{ .Site.Params.espocrmHubManagedProductId }}'; const LEGACY_STORE_URL = '{{ .Site.Params.legacyStoreUrl }}'; const HUB_MANAGED_DOMAIN = '{{ .Site.Params.hubManagedDomain }}'; const STRIPE_PK = '{{ .Site.Params.stripePk }}'; diff --git a/assets/js/hubsubscription.js b/assets/js/hubsubscription.js index 4d32fbfef..826c47bbf 100644 --- a/assets/js/hubsubscription.js +++ b/assets/js/hubsubscription.js @@ -2,12 +2,17 @@ const BILLING_SESSION_URL = API_BASE_URL + '/billing/session'; const BILLING_CUSTOMER_URL = API_BASE_URL + '/billing/customers/by-hub-id'; +const CARD_CHECKOUT_URL = API_BASE_URL + '/billing/paddle-classic/checkout'; +const INVOICE_CHECKOUT_URL = API_BASE_URL + '/billing/espocrm/checkout'; +const INVOICE_PRICE_URL = API_BASE_URL + '/billing/espocrm/checkout/price'; +const CHECKOUT_CONTEXT_URL = API_BASE_URL + '/billing/espocrm/checkout/context'; +const MANAGE_SUBSCRIPTION_BASE_URL = API_BASE_URL + '/billing/manage/subscription'; 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'; -const UPDATE_PAYMENT_METHOD_URL = LEGACY_STORE_URL + '/hub/update-payment-method'; const GET_LICENSE_URL = API_BASE_URL + '/licenses/hub'; +// EspoCRM holds more than one account for these details, and only sales can merge them. +const DUPLICATE_RECORD_ERROR = 'We already hold a record for these billing details. Please contact us so we can complete your purchase.'; + class HubSubscription { constructor(form, subscriptionData, searchParams) { @@ -15,11 +20,8 @@ class HubSubscription { this._subscriptionData = subscriptionData; this._subscriptionData.oldLicense = searchParams.get('oldLicense'); if (this._subscriptionData.oldLicense) { - try { - let base64 = this._subscriptionData.oldLicense.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); - this._subscriptionData.hubId = JSON.parse(atob(base64)).jti; - } catch (e) { - console.error('Failed to parse hub token:', e); + this._subscriptionData.hubId = this.extractHubId(this._subscriptionData.oldLicense); + if (!this._subscriptionData.hubId) { this._subscriptionData.oldLicense = null; } } @@ -29,17 +31,19 @@ class HubSubscription { this._subscriptionData.returnUrl = returnUrl; } // Capture the Hub's `token_transfer` value (how the license should be delivered) so it can be stored in - // the billing session; default to delivering it as a query parameter. + // the billing session. this._subscriptionData.tokenTransfer = searchParams.get('token_transfer') ?? 'queryParam'; this._subscriptionData.session = searchParams.get('session'); + this._invoicePriceRequestId = 0; + this._awaitingInvoiceCaptcha = false; if (this._subscriptionData.session) { // We returned from the confirmation link (/hub/billing?session=): resolve the verified - // billing session and continue into the existing subscription flow. + // billing session and continue into the manage or checkout flow. this._subscriptionData.state = 'LOADING'; 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. + // confirmation link before we can manage their subscription or check out. this._subscriptionData.state = 'CREATE_SESSION'; } this._paddle = $.ajax({ @@ -55,49 +59,81 @@ class HubSubscription { }); } - loadSubscription() { + extractHubId(license) { + try { + let base64 = license.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(base64)).jti; + } catch (e) { + console.error('Failed to parse hub token:', e); + return null; + } + } + + authHeaders() { + return { Authorization: 'Bearer ' + this._subscriptionData.session }; + } + + loadCheckoutPrerequisites() { this.loadCustomBilling(() => { - this.loadPrice(() => { - this._subscriptionData.inProgress = true; - this._subscriptionData.errorMessage = ''; - $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'GET', - data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session - } - }).done(data => { - this.onLoadSubscriptionSucceeded(data); - }).fail(xhr => { - this.onLoadSubscriptionFailed(xhr.status, xhr.responseJSON?.message || 'Loading subscription failed.'); + this.loadCheckoutContext(() => { + this.loadPrice(() => { + this._subscriptionData.state = 'NEW_CUSTOMER'; + this._subscriptionData.errorMessage = ''; + this._subscriptionData.inProgress = false; }); }); }); } + loadManageSubscription() { + this.loadCustomBilling(() => { + // the change-seats stepper bounds itself by the same contractual range the checkout uses + this.loadCheckoutContext(() => this.loadManageSubscriptionDetails(), () => this.loadManageSubscriptionDetails()); + }); + } + + loadManageSubscriptionDetails() { + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + $.ajax({ + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}`, + type: 'GET', + headers: this.authHeaders() + }).done(data => { + this.onLoadSubscriptionSucceeded(data); + }).fail(xhr => { + this.onLoadSubscriptionFailed(xhr.status, 'Loading subscription failed.'); + }); + } + onLoadSubscriptionSucceeded(data) { - this._subscriptionData.details = data.subscription; - if (data.subscription.quantity) { - this._subscriptionData.quantity = data.subscription.quantity; - } + this._subscriptionData.details = { + processor: data.processor, + status: data.status, + seats: data.seats, + current_period_end: data.current_period_end + }; + this._subscriptionData.quantity = data.seats; this._subscriptionData.state = 'EXISTING_CUSTOMER'; this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; - this._subscriptionData.needsTokenRefresh = true; + this.refreshToken(); } onLoadSubscriptionFailed(status, error) { - if (status == 404) { - this._subscriptionData.state = 'NEW_CUSTOMER'; - this._subscriptionData.errorMessage = ''; - } else if (status == 400) { - // Assuming that the error is due to the session being missing. + if (status == 401) { this._subscriptionData.state = 'CREATE_SESSION'; this._subscriptionData.errorMessage = ''; + } else if (status == 404 && this._subscriptionData.returnUrl) { + this.loadCheckoutPrerequisites(); + return; + } else if (status == 404) { + this._subscriptionData.state = 'MISSING_PARAMS'; + this._subscriptionData.errorMessage = ''; } else { - this._subscriptionData.state = 'CREATE_SESSION'; - this._subscriptionData.errorMessage = error; + // A transient fault leaves the session valid, so another confirmation link would change nothing. + this.onLoadFailed(error); + return; } this._subscriptionData.inProgress = false; } @@ -111,7 +147,7 @@ class HubSubscription { }).done(data => { this.onLoadBillingSessionSucceeded(data); }).fail(xhr => { - this.onLoadBillingSessionFailed(xhr.status, xhr.responseJSON?.message || 'Loading billing session failed.'); + this.onLoadBillingSessionFailed(xhr.status, 'Loading billing session failed.'); }); } @@ -121,17 +157,25 @@ class HubSubscription { this._subscriptionData.returnUrl = data.returnUrl; this._subscriptionData.tokenTransfer = data.tokenTransfer; this._subscriptionData.errorMessage = ''; - // The session is verified; hand off to the existing subscription flow (store + Paddle). - this.loadSubscription(); + // The session is verified; a session already linked to a billing manages it (the manage endpoints + // only accept linked sessions), an unlinked one belongs to a new customer heading into checkout. + if (data.billingId) { + this.loadManageSubscription(); + } else { + this.loadCheckoutPrerequisites(); + } } onLoadBillingSessionFailed(status, error) { if (status == 404) { this._subscriptionData.state = 'LINK_EXPIRED'; this._subscriptionData.errorMessage = ''; - } else { + } else if (this._subscriptionData.hubId) { this._subscriptionData.state = 'CREATE_SESSION'; this._subscriptionData.errorMessage = error; + } else { + this._subscriptionData.state = 'MISSING_PARAMS'; + this._subscriptionData.errorMessage = ''; } this._subscriptionData.inProgress = false; } @@ -142,12 +186,15 @@ class HubSubscription { // 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' + url: BILLING_CUSTOMER_URL + '/' + encodeURIComponent(this._subscriptionData.hubId), + type: 'GET', + data: { + captcha: this._subscriptionData.captcha + } }).done(data => { this.onLookupCustomerSucceeded(data); }).fail(xhr => { - this.onLookupCustomerFailed(xhr.status, xhr.responseJSON?.message || 'Looking up your subscription failed.'); + this.onLookupCustomerFailed(xhr.status, 'Looking up your subscription failed.'); }); } @@ -162,23 +209,20 @@ class HubSubscription { } onLookupCustomerFailed(status, error) { + // 404 means the Hub is not linked to a customer yet: ask for the purchase email. Any other + // failure falls back to the same manual entry (the lookup captcha solves only once, so a + // transient failure cannot re-trigger the lookup, and for a known hub the server ignores the + // entered address and mails the one on file) — but keeps the error visible. 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; - } + this._subscriptionData.needsEmail = true; + this._subscriptionData.redactedEmail = null; + this._subscriptionData.lookupDone = true; + this._subscriptionData.errorMessage = status == 404 ? '' : error; } createSession() { if (!$(this._form)[0].checkValidity()) { - $(this._form).find(':input').addClass('show-invalid'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } @@ -202,7 +246,7 @@ class HubSubscription { }).done(_ => { this.onCreateSessionSucceeded(); }).fail(xhr => { - this.onCreateSessionFailed(xhr.responseJSON?.message || 'Requesting confirmation link failed.'); + this.onCreateSessionFailed('Requesting confirmation link failed.'); }); } @@ -228,34 +272,31 @@ class HubSubscription { } }).done(data => { this.onLoadCustomBillingSucceeded(data); - if (data.custom_billing.manual_invoice) { - this._subscriptionData.state = 'MANUAL_INVOICE'; - } else { - continueHandler(); - } - }).fail(xhr => { - this.onLoadCustomBillingFailed(xhr.status, xhr.responseJSON?.message || 'Loading custom billing options failed.'); - if (xhr.status == 404 && xhr.responseJSON?.status == 'error') { - continueHandler(); - } + continueHandler(); + }).fail(() => { + this.onLoadFailed('Loading custom billing options failed.'); }); } onLoadCustomBillingSucceeded(data) { - this._subscriptionData.customBilling = data.custom_billing; - this._subscriptionData.quantity = this._subscriptionData.customBilling.quantity || this._subscriptionData.quantity; - this._subscriptionData.email = this._subscriptionData.customBilling.email || this._subscriptionData.email; + // custom_billing is null when the hub has no custom billing + this._subscriptionData.customBilling = data.custom_billing || null; + if (this._subscriptionData.customBilling) { + this._subscriptionData.quantity = this._subscriptionData.customBilling.quantity || this._subscriptionData.quantity; + this._subscriptionData.email = this._subscriptionData.customBilling.email || this._subscriptionData.email; + } this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; } - onLoadCustomBillingFailed(status, error) { - if (status == 404) { - this._subscriptionData.customBilling = null; - this._subscriptionData.errorMessage = ''; - } else { - this._subscriptionData.errorMessage = error; + // While the page is still on the spinner there is no screen to fall back to, so the failure gets its own + // screen. The loaders also re-run from the rendered manage screen, where the error belongs inline instead + // of replacing it. + onLoadFailed(error) { + if (this._subscriptionData.state === 'LOADING') { + this._subscriptionData.state = 'LOAD_FAILED'; } + this._subscriptionData.errorMessage = error; this._subscriptionData.inProgress = false; } @@ -274,8 +315,8 @@ class HubSubscription { }).done(data => { this.onLoadPriceSucceeded(data, yearlyPlanId, monthlyPlanId); continueHandler(); - }).fail(xhr => { - this.onLoadPriceFailed(xhr.responseJSON?.message || 'Loading price failed.'); + }).fail(() => { + this.onLoadFailed('Loading price failed.'); }); } @@ -325,44 +366,45 @@ class HubSubscription { return price ? parseFloat(price.split(':')[1]) : null; } - onLoadPriceFailed(error) { - this._subscriptionData.errorMessage = error; - this._subscriptionData.inProgress = false; + selectedPlanId() { + let isManaged = this._subscriptionData.customBilling?.managed; + let isMonthly = this._subscriptionData.billingInterval === 'monthly'; + if (isManaged) { + return isMonthly ? PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID : PADDLE_HUB_MANAGED_YEARLY_PLAN_ID; + } else { + return isMonthly ? PADDLE_HUB_SELF_HOSTED_MONTHLY_PLAN_ID : PADDLE_HUB_SELF_HOSTED_YEARLY_PLAN_ID; + } } checkout(locale) { if (!$(this._form)[0].checkValidity()) { - $(this._form).find(':input').addClass('show-invalid'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; - let isManaged = this._subscriptionData.customBilling?.managed; - let isMonthly = this._subscriptionData.billingInterval === 'monthly'; - let planId; - if (isManaged) { - planId = isMonthly ? PADDLE_HUB_MANAGED_MONTHLY_PLAN_ID : PADDLE_HUB_MANAGED_YEARLY_PLAN_ID; - } else { - planId = isMonthly ? PADDLE_HUB_SELF_HOSTED_MONTHLY_PLAN_ID : PADDLE_HUB_SELF_HOSTED_YEARLY_PLAN_ID; - } - this.customCheckout(planId, locale); + this.customCheckout(this.selectedPlanId(), locale); + // refresh the card captcha for a potential retry; it is the only altcha element rendered + // while the card method is selected + this._form.querySelector('altcha-widget')?.reset(); } customCheckout(productId, locale) { $.ajax({ - url: GENERATE_PAY_LINK_URL, + url: CARD_CHECKOUT_URL, type: 'POST', data: { + captcha: this._subscriptionData.cardCaptcha, hub_id: this._subscriptionData.hubId, product_id: productId, - quantity: this._subscriptionData.quantity + quantity: this._subscriptionData.quantity, + session: this._subscriptionData.session } }).done(data => { this.openPaddleCheckout(data.pay_link, locale); }).fail(xhr => { - this.onPostFailed(xhr.responseJSON?.message || 'Generating pay link failed.'); + this.onPostFailed('Checkout failed.'); }); } @@ -371,6 +413,7 @@ class HubSubscription { paddle.Checkout.open({ override: payLink, email: this._subscriptionData.email, + disableLogout: true, locale: locale, passthrough: JSON.stringify({ hub_id: this._subscriptionData.hubId, session: this._subscriptionData.session }), successCallback: data => this.getPaddleOrderDetails(data.checkout.id), @@ -386,7 +429,7 @@ class HubSubscription { paddle.Order.details(checkoutId, data => { let subscriptionId = data.order.subscription_id; if (subscriptionId) { - this.post(subscriptionId); + this.onCheckoutSucceeded(); } else { this._subscriptionData.errorMessage = 'Retrieving subscription failed. Please check your emails instead.'; } @@ -394,29 +437,211 @@ class HubSubscription { }); } - post(subscriptionId) { + invoiceProductId() { + return this._subscriptionData.customBilling?.managed ? ESPOCRM_HUB_MANAGED_PRODUCT_ID : ESPOCRM_HUB_SELF_HOSTED_PRODUCT_ID; + } + + loadInvoicePrice() { + if (!this.invoiceProductId()) { + return; + } + // Keep the previous price visible (dimmed) while reloading, so the summary doesn't jump on seat changes. + this._subscriptionData.invoicePriceLoading = true; + this._subscriptionData.invoicePriceError = false; + // Stale-response guard: only the latest request may populate the summary after rapid seat changes. + let requestId = ++this._invoicePriceRequestId; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, + url: INVOICE_PRICE_URL, + type: 'GET', + data: { + hub_id: this._subscriptionData.hubId, + product_id: this.invoiceProductId(), + quantity: this._subscriptionData.quantity, + session: this._subscriptionData.session + } + }).done(data => { + if (requestId === this._invoicePriceRequestId) { + this._subscriptionData.invoicePrice = data; + this._subscriptionData.invoicePriceLoading = false; + } + }).fail(_ => { + if (requestId === this._invoicePriceRequestId) { + this._subscriptionData.invoicePrice = null; + this._subscriptionData.invoicePriceError = true; + this._subscriptionData.invoicePriceLoading = false; + } + }); + } + + clampInvoiceQuantity(quantity) { + let context = this._subscriptionData.checkoutContext; + this._subscriptionData.quantity = Math.min(context.quantity_max, Math.max(context.quantity_min, quantity || context.quantity_min)); + } + + setInvoiceQuantity(quantity) { + this.clampInvoiceQuantity(quantity); + this.loadInvoicePrice(); + } + + changeInvoiceQuantity(delta) { + this.setInvoiceQuantity(parseInt(this._subscriptionData.quantity, 10) + delta); + } + + openInvoiceCheckoutModal() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + return; + } + this._subscriptionData.errorMessage = ''; + this._awaitingInvoiceCaptcha = false; + this._subscriptionData.invoiceCheckoutModal.open = true; + this.loadInvoicePrice(); + } + + // The purchase bills a returning customer to the account already on file, so the page shows those details + // instead of collecting ones the purchase discards. + loadCheckoutContext(continueHandler, failureHandler) { + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + $.ajax({ + url: CHECKOUT_CONTEXT_URL, + type: 'GET', + data: { + hub_id: this._subscriptionData.hubId, + session: this._subscriptionData.session + } + }).done(data => { + this.onLoadCheckoutContextSucceeded(data); + continueHandler(); + }).fail(xhr => { + if (failureHandler) { + failureHandler(); + } else { + this.onLoadFailed(this.checkoutContextError(xhr.status)); + } + }); + } + + checkoutContextError(status) { + if (status === 409) { + return DUPLICATE_RECORD_ERROR; + } + return 'Loading the checkout options failed.'; + } + + onLoadCheckoutContextSucceeded(data) { + this._subscriptionData.checkoutContext = data; + this.clampInvoiceQuantity(this._subscriptionData.quantity); + let details = data.billing_details; + if (details) { + // an account may carry no VAT id or no address, and the form binds strings + this._subscriptionData.invoice = { + account_name: details.account_name ?? '', + vat_id: details.vat_id ?? '', + address_street: details.address_street ?? '', + address_postal_code: details.address_postal_code ?? '', + address_city: details.address_city ?? '', + address_country: details.address_country ?? '' + }; + } + this._subscriptionData.errorMessage = ''; + this._subscriptionData.inProgress = false; + } + + showInvalidFields() { + $(this._form).find(':input').addClass('show-invalid'); + this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + } + + // Excludes the altcha widget's internal checkbox, whose checked state is mid-flight while the + // buy button re-solves a challenge. + invoiceFieldsValid() { + return $(this._form).find(':input').toArray().filter(el => !el.closest('altcha-widget')).every(el => el.checkValidity()); + } + + startInvoiceCheckout() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + return; + } + // Challenges expire server-side within a minute, so solve one fresh at buy time; the widget's + // verified event then triggers onInvoiceCaptchaVerified() with the new payload. The modal's + // widget is the only altcha element rendered while the invoice method is selected. + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + this._awaitingInvoiceCaptcha = true; + let captchaWidget = this._form.querySelector('altcha-widget'); + captchaWidget?.reset(); + captchaWidget?.verify(); + } + + // One-shot: altcha's reset() does not abort an in-flight solve, so a stale solve and the fresh + // one can both fire a verified event — only the first may trigger the checkout, and only while + // the modal is still open (a solve settling after cancel must not buy anything). + onInvoiceCaptchaVerified() { + if (!this._subscriptionData.invoiceCheckoutModal.open) { + this._awaitingInvoiceCaptcha = false; + return; + } + if (!this._awaitingInvoiceCaptcha) { + return; + } + this._awaitingInvoiceCaptcha = false; + this.invoiceCheckout(); + } + + invoiceCheckout() { + if (!this.invoiceFieldsValid()) { + this.showInvalidFields(); + this._subscriptionData.inProgress = false; + return; + } + + this._subscriptionData.inProgress = true; + this._subscriptionData.errorMessage = ''; + let invoice = this._subscriptionData.invoice; + $.ajax({ + url: INVOICE_CHECKOUT_URL, type: 'POST', data: { + captcha: this._subscriptionData.invoiceCaptcha, hub_id: this._subscriptionData.hubId, + product_id: this.invoiceProductId(), + quantity: this._subscriptionData.quantity, session: this._subscriptionData.session, - subscription_id: subscriptionId + account_name: invoice.account_name, + vat_id: invoice.vat_id, + address_street: invoice.address_street, + address_postal_code: invoice.address_postal_code, + address_city: invoice.address_city, + address_country: invoice.address_country } - }).done(data => { - this.onPostSucceeded(data); + }).done(_ => { + this.onCheckoutSucceeded(); }).fail(xhr => { - this.onPostFailed(xhr.responseJSON?.message || 'Adding subscription failed.'); + this.onPostFailed(this.invoiceCheckoutError(xhr.status)); }); } - onPostSucceeded(data) { - this._subscriptionData.state = 'EXISTING_CUSTOMER'; - this._subscriptionData.details = data.subscription; + invoiceCheckoutError(status) { + if (status === 409) { + return DUPLICATE_RECORD_ERROR; + } + // The account on file supplies the billing details, and this form shows them read-only, so nothing the + // customer can reach here explains the refusal. + if (status === 400 && this._subscriptionData.checkoutContext?.billing_details) { + return 'We cannot complete this purchase automatically. Please contact us and we will finish it for you.'; + } + return 'Creating subscription failed.'; + } + + onCheckoutSucceeded() { + this._subscriptionData.state = 'CHECKOUT_SUCCESS'; + this._subscriptionData.invoiceCheckoutModal.open = false; this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; - this._subscriptionData.shouldTransferToHub = true; - this._subscriptionData.needsTokenRefresh = true; + this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl; + this.refreshToken(); } onPostFailed(error) { @@ -427,129 +652,100 @@ class HubSubscription { updatePaymentMethod(locale) { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; + this._subscriptionData.shouldTransferToHub = false; $.ajax({ - url: UPDATE_PAYMENT_METHOD_URL, + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/payment-method`, type: 'GET', - data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, - subscription_id: this._subscriptionData.details.subscription_id - } + headers: this.authHeaders() }).done(data => { this._paddle.then(paddle => { paddle.Checkout.open({ override: data.url, locale: locale, - successCallback: _ => this.loadSubscription(), + successCallback: _ => this.loadManageSubscription(), closeCallback: () => { this._subscriptionData.inProgress = false; } }); }); }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating payment method failed.'); + this.onPutFailed(xhr.status, 'Updating payment method failed.'); }); } pause() { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; + // a stale transfer intent from an earlier action must not redirect after this refresh + this._subscriptionData.shouldTransferToHub = false; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'PUT', - data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, - pause: true - } - }).done(data => { - this.onPutSucceeded(data, false); + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/pause`, + type: 'POST', + headers: this.authHeaders() + }).done(_ => { + this.loadManageSubscription(); }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.'); + this.onPutFailed(xhr.status, 'Updating subscription failed.'); }); } askForRestartConfirmation() { - this._subscriptionData.restartModal.nextPayment = null; this._subscriptionData.restartModal.open = true; - this.previewRestart(); - } - - previewRestart() { - this._subscriptionData.inProgress = true; - this._subscriptionData.errorMessage = ''; - $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'PUT', - data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, - pause: false, - preview: true - } - }).done(data => { - this._subscriptionData.restartModal.nextPayment = data.subscription.next_payment; - this._subscriptionData.errorMessage = ''; - this._subscriptionData.inProgress = false; - }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Calculating price failed.'); - }); } restart() { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'PUT', - data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, - pause: false - } - }).done(data => { - this.onPutSucceeded(data, this._subscriptionData.details.state == 'paused'); + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/resume`, + type: 'POST', + headers: this.authHeaders() + }).done(_ => { + this._subscriptionData.restartModal.open = false; + this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl; + this.loadManageSubscription(); }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.'); + this.onPutFailed(xhr.status, 'Updating subscription failed.'); }); } openChangeSeatsModal() { - this._subscriptionData.quantity = this._subscriptionData.details.quantity; - this._subscriptionData.changeSeatsModal.immediatePayment = null; + this._subscriptionData.quantity = this._subscriptionData.details.seats; + this._subscriptionData.changeSeatsModal.nextPayment = null; + this._subscriptionData.changeSeatsModal.invoicePreview = null; this._subscriptionData.changeSeatsModal.confirmation = false; this._subscriptionData.changeSeatsModal.open = true; } askForChangeSeatsConfirmation() { if (!$(this._form)[0].checkValidity()) { - $(this._form).find(':input').addClass('show-invalid'); - this._subscriptionData.errorMessage = 'Please fill in all required fields.'; + this.showInvalidFields(); return; } this._subscriptionData.changeSeatsModal.confirmation = true; - this.previewChangeQuantity(); + if (this._subscriptionData.details.processor == 'PADDLE_CLASSIC' || this._subscriptionData.details.processor == 'ESPOCRM') { + this.previewChangeQuantity(); + } } previewChangeQuantity() { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'PUT', + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/seats/preview`, + type: 'POST', + headers: this.authHeaders(), data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, - quantity: this._subscriptionData.quantity, - preview: true + quantity: this._subscriptionData.quantity } }).done(data => { - this._subscriptionData.changeSeatsModal.immediatePayment = data.subscription.immediate_payment; + this._subscriptionData.changeSeatsModal.nextPayment = data.next_payment; + this._subscriptionData.changeSeatsModal.invoicePreview = data.prorated_amount != null ? data : null; this._subscriptionData.errorMessage = ''; this._subscriptionData.inProgress = false; }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Calculating price failed.'); + this.onPutFailed(xhr.status, 'Calculating price failed.'); }); } @@ -557,31 +753,21 @@ class HubSubscription { this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; $.ajax({ - url: MANAGE_SUBSCRIPTION_URL, - type: 'PUT', + url: `${MANAGE_SUBSCRIPTION_BASE_URL}/${this._subscriptionData.hubId}/seats`, + type: 'POST', + headers: this.authHeaders(), data: { - hub_id: this._subscriptionData.hubId, - session: this._subscriptionData.session, quantity: this._subscriptionData.quantity } - }).done(data => { + }).done(_ => { this._subscriptionData.changeSeatsModal.open = false; - this.onPutSucceeded(data, true); + this._subscriptionData.shouldTransferToHub = !!this._subscriptionData.returnUrl; + this.loadManageSubscription(); }).fail(xhr => { - this.onPutFailed(xhr.status, xhr.responseJSON?.message || 'Updating subscription failed.'); + this.onPutFailed(xhr.status, 'Updating subscription failed.'); }); } - onPutSucceeded(data, shouldOpenReturnUrl) { - this._subscriptionData.details = data.subscription; - this._subscriptionData.errorMessage = ''; - this._subscriptionData.inProgress = false; - if (shouldOpenReturnUrl) { - this._subscriptionData.shouldTransferToHub = true; - this._subscriptionData.needsTokenRefresh = true; - } - } - onPutFailed(status, error) { if (status == 401) { this._subscriptionData.state = 'CREATE_SESSION'; @@ -591,6 +777,7 @@ class HubSubscription { } refreshToken() { + this._subscriptionData.needsTokenRefresh = true; this._subscriptionData.inProgress = true; this._subscriptionData.errorMessage = ''; $.ajax({ @@ -609,7 +796,9 @@ class HubSubscription { this.transferTokenToHub(); } }).fail(xhr => { - this._subscriptionData.errorMessage = xhr.responseJSON?.message || 'Refreshing license failed.'; + // Expected for a card checkout until Paddle's payment webhook links the session to the new + // billing; the license block then offers a retry. + this._subscriptionData.errorMessage = 'Refreshing license failed.'; this._subscriptionData.needsTokenRefresh = false; this._subscriptionData.inProgress = false; }); @@ -617,7 +806,6 @@ class HubSubscription { transferTokenToHub() { if (this._subscriptionData.tokenTransfer === 'queryParam') { - // Deliver the refreshed license to the Hub directly as a query parameter. location.href = this._subscriptionData.returnUrl + '?token=' + encodeURIComponent(this._subscriptionData.token); } else if (this._subscriptionData.tokenTransfer === 'session') { // Hand the Hub the billing session id instead; it resolves the license itself. diff --git a/config/_default/hugo.yaml b/config/_default/hugo.yaml index a54550588..e8269bad9 100644 --- a/config/_default/hugo.yaml +++ b/config/_default/hugo.yaml @@ -51,6 +51,8 @@ module: target: assets/js/jquery - source: node_modules/alpinejs/dist target: assets/js/alpinejs + - source: node_modules/@alpinejs/focus/dist + target: assets/js/alpinejs-focus - source: node_modules/lazysizes target: assets/js/lazysizes - source: node_modules/js-yaml/dist diff --git a/config/development/params.yaml b/config/development/params.yaml index 21079dd44..c6e0545d3 100644 --- a/config/development/params.yaml +++ b/config/development/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 42235 paddleHubManagedMonthlyPlanId: 82379 paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices +# ESPOCRM +espocrmHubSelfHostedProductId: 69bd302d5c65eabaa +espocrmHubManagedProductId: 69bd302d521a70103 + # STRIPE stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF diff --git a/config/production/params.yaml b/config/production/params.yaml index cf5881f33..78d001dfc 100644 --- a/config/production/params.yaml +++ b/config/production/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 807339 paddleHubManagedMonthlyPlanId: 914173 paddlePricesUrl: https://checkout.paddle.com/api/2.0/prices +# ESPOCRM +espocrmHubSelfHostedProductId: 6a4fd3f914e21a1a7 +espocrmHubManagedProductId: 6a4fd3f9039df8c27 + # STRIPE stripePk: pk_live_eSasX216vGvC26GdbVwA011V diff --git a/config/staging/params.yaml b/config/staging/params.yaml index e744c8e44..5eac22a60 100644 --- a/config/staging/params.yaml +++ b/config/staging/params.yaml @@ -21,5 +21,9 @@ paddleHubManagedYearlyPlanId: 42235 paddleHubManagedMonthlyPlanId: 82379 paddlePricesUrl: https://sandbox-checkout.paddle.com/api/2.0/prices +# ESPOCRM +espocrmHubSelfHostedProductId: 69bd302d5c65eabaa +espocrmHubManagedProductId: 69bd302d521a70103 + # STRIPE stripePk: pk_test_51RCM24IBZmkR4F9UiLBiSmsAnJvWqmHcDLxXR8ABKK1MNsZk3zCk2VJW7ZfaBlD81zpQxCX243sS3LEp9dABwiG800kJnGykDF diff --git a/data/de/eu_countries.yaml b/data/de/eu_countries.yaml new file mode 100644 index 000000000..fd975739e --- /dev/null +++ b/data/de/eu_countries.yaml @@ -0,0 +1,54 @@ +- code: AT + name: Österreich +- code: BE + name: Belgien +- code: BG + name: Bulgarien +- code: HR + name: Kroatien +- code: CY + name: Zypern +- code: CZ + name: Tschechien +- code: DK + name: Dänemark +- code: EE + name: Estland +- code: FI + name: Finnland +- code: FR + name: Frankreich +- code: DE + name: Deutschland +- code: GR + name: Griechenland +- code: HU + name: Ungarn +- code: IE + name: Irland +- code: IT + name: Italien +- code: LV + name: Lettland +- code: LT + name: Litauen +- code: LU + name: Luxemburg +- code: MT + name: Malta +- code: NL + name: Niederlande +- code: PL + name: Polen +- code: PT + name: Portugal +- code: RO + name: Rumänien +- code: SK + name: Slowakei +- code: SI + name: Slowenien +- code: ES + name: Spanien +- code: SE + name: Schweden diff --git a/data/en/eu_countries.yaml b/data/en/eu_countries.yaml new file mode 100644 index 000000000..c0f1063df --- /dev/null +++ b/data/en/eu_countries.yaml @@ -0,0 +1,54 @@ +- code: AT + name: Austria +- code: BE + name: Belgium +- code: BG + name: Bulgaria +- code: HR + name: Croatia +- code: CY + name: Cyprus +- code: CZ + name: Czechia +- code: DK + name: Denmark +- code: EE + name: Estonia +- code: FI + name: Finland +- code: FR + name: France +- code: DE + name: Germany +- code: GR + name: Greece +- code: HU + name: Hungary +- code: IE + name: Ireland +- code: IT + name: Italy +- code: LV + name: Latvia +- code: LT + name: Lithuania +- code: LU + name: Luxembourg +- code: MT + name: Malta +- code: NL + name: Netherlands +- code: PL + name: Poland +- code: PT + name: Portugal +- code: RO + name: Romania +- code: SK + name: Slovakia +- code: SI + name: Slovenia +- code: ES + name: Spain +- code: SE + name: Sweden diff --git a/i18n/de.yaml b/i18n/de.yaml index 7f9db8e59..26b5a93a5 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -34,8 +34,6 @@ translation: "Ich bin damit einverstanden, Neuigkeiten von Cryptomator Hub zu erhalten, und akzeptiere die Datenschutzerklärung." - id: accept_hub_newsletter_optional translation: "Ich bin damit einverstanden, Neuigkeiten von Cryptomator Hub per E-Mail zu erhalten (optional)." -- id: accept_hub_managed_early_access_optional - translation: "Early Access Cryptomator Hub 2.0.0 mit brandneuen Features und einer verlängerten kostenlosen 60-Tage-Testphase testen (optional)." - id: accept_privacy translation: "Ich akzeptiere die Datenschutzerklärung." - id: accept_privacy_implicitly @@ -408,10 +406,6 @@ translation: "Jetzt anmelden" - id: hub_header_managed_cta_description translation: "Startet eure kostenlose 30-Tage-Testversion noch heute, keine Kreditkarte erforderlich." -- id: hub_header_kicker_early_access - translation: "Hub 2.0.0 Early Access*" -- id: hub_header_early_access_note - translation: "Early Access beinhaltet brandneue Features und eine verlängerte kostenlose 60-Tage-Testphase." - id: hub_header_group_magic_alt translation: "Cryptobot verbindet eine Gruppe von Menschen mit seinen magischen Fähigkeiten durch Zero-Knowledge-Verschlüsselung" @@ -540,12 +534,13 @@ - 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_loadfailed_description + translation: "Wir konnten deine Abonnement-Informationen nicht laden. Bitte kontaktiere uns, falls das Problem weiterhin besteht." + - id: hub_billing_manage_status_title translation: "Status" - id: hub_billing_manage_status_active translation: "Aktiv" -- id: hub_billing_manage_status_pastdue - translation: "Überfällig" - id: hub_billing_manage_status_trialing translation: "Testphase" - id: hub_billing_manage_status_paused @@ -568,12 +563,10 @@ - id: hub_billing_manage_payment_info_title translation: "Zahlungsinformationen" -- id: hub_billing_manage_payment_info_credit_card - translation: "Kreditkarte" -- id: hub_billing_manage_payment_info_credit_card_last_four_digits_description - translation: "Endet mit" -- id: hub_billing_manage_payment_info_paypal - translation: "PayPal" +- id: hub_billing_manage_payment_info_card + translation: "Karte / PayPal" +- id: hub_billing_manage_payment_info_invoice + translation: "Rechnung" - id: hub_billing_manage_payment_info_update_action translation: "Zahlungsmethode aktualisieren" @@ -586,8 +579,6 @@ - id: hub_billing_manage_license_key_retry_action translation: "Erneut versuchen" -- id: hub_billing_manage_modal_charge_amount_description - translation: "Rechnungsbetrag" - id: hub_billing_manage_modal_continue translation: "Weiter" - id: hub_billing_manage_modal_confirm @@ -610,8 +601,22 @@ translation: "Neue Anzahl der Sitze" - id: hub_billing_manage_change_quantity_confirmation_increase_warning translation: "Du bist dabei, das Sitzplatzlimit zu erhöhen. Wenn du dies bestätigst, wird die die Differenz sofort in Rechnung gestellt." +- id: hub_billing_manage_change_quantity_confirmation_increase_warning_invoice + translation: "Du bist dabei, das Sitzplatzlimit zu erhöhen. Wenn du dies bestätigst, werden die zusätzlichen Sitze per Rechnung abgerechnet." - id: hub_billing_manage_change_quantity_confirmation_decrease_warning translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird deine nächste Zahlung um die Differenz reduziert." +- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice + translation: "Du bist dabei, das Sitzplatzlimit zu verringern. Wenn du dies bestätigst, wird die Reduzierung auf deiner nächsten Rechnung berücksichtigt." +- id: hub_billing_manage_change_quantity_confirmation_prorated_invoice + translation: "Jetzt in Rechnung gestellter Betrag" +- id: hub_billing_manage_change_quantity_confirmation_credited_invoice + translation: "Gutschrift auf dein Konto" +- id: hub_billing_manage_change_quantity_confirmation_incl_vat + translation: "inkl. {rate} % USt." +- id: hub_billing_manage_change_quantity_confirmation_available_credit_invoice + translation: "Verfügbares Guthaben (wird mit einer künftigen Rechnung verrechnet, keine Rückzahlung)" +- id: hub_billing_manage_change_quantity_confirmation_new_recurring_invoice + translation: "Neuer Jahresbetrag (netto)" - id: hub_billing_checkout_description translation: "Schöpfe das volle Potenzial deiner Hub-Instanz aus und hol dein Team mit der clientseitigen Verschlüsselung für deinen Cloud-Speicher an Bord." @@ -643,13 +648,74 @@ translation: "Nur Nutzer, die Zugang zu Tresoren haben, werden auf die Anzahl der Sitze angerechnet." - id: hub_billing_checkout_standard_email translation: "E-Mail" -- id: hub_billing_checkout_standard_email_placeholder - translation: "E-Mail-Adresse" - id: hub_billing_checkout_standard_instruction translation: "Zahlungen werden über Paddle abgewickelt." - id: hub_billing_checkout_standard_submit translation: "Zur Zahlung" +- id: hub_billing_checkout_payment_method + translation: "Zahlungsmethode" +- id: hub_billing_checkout_payment_method_card + translation: "Per Karte zahlen" +- id: hub_billing_checkout_payment_method_invoice + translation: "Kauf auf Rechnung" + +- id: hub_billing_checkout_invoice_email + translation: "E-Mail" +- id: hub_billing_checkout_invoice_email_hint + translation: "Mit dieser E-Mail-Adresse verwaltest du später dein Abonnement." +- id: hub_billing_checkout_invoice_account_name + translation: "Firmenname" +- id: hub_billing_checkout_invoice_address_street + translation: "Straße und Hausnummer" +- id: hub_billing_checkout_invoice_address_postal_code + translation: "Postleitzahl" +- id: hub_billing_checkout_invoice_address_city + translation: "Stadt" +- id: hub_billing_checkout_invoice_address_country + translation: "Land" +- id: hub_billing_checkout_invoice_address_country_placeholder + translation: "Bitte auswählen" +- id: hub_billing_checkout_invoice_non_eu_hint + translation: "Kauf auf Rechnung ist nur innerhalb der EU möglich. Außerhalb der EU? Kontaktiere uns bitte über die Enterprise-Option." +- id: hub_billing_checkout_invoice_vat_id + translation: "USt-IdNr." +- id: hub_billing_checkout_invoice_details_on_file + translation: "Dies sind die Rechnungsdaten, die wir zu deiner E-Mail-Adresse gespeichert haben. Melde dich bei uns, wenn sie geändert werden müssen." +- id: hub_billing_checkout_invoice_vat_id_hint + translation: "Erforderlich für EU-Länder außerhalb Deutschlands." +- id: hub_billing_checkout_invoice_instruction + translation: "Eine Rechnung wird ausgestellt und an deine E-Mail-Adresse gesendet." +- id: hub_billing_checkout_invoice_submit + translation: "Auf Rechnung kaufen" +- id: hub_billing_checkout_invoice_total_suffix + translation: "pro Jahr (netto)" +- id: hub_billing_checkout_invoice_total_vat_hint + translation: "zzgl. 19 % USt." +- id: hub_billing_checkout_invoice_total_reverse_charge_hint + translation: "Reverse-Charge-Verfahren – Steuerschuldnerschaft des Leistungsempfängers" +- id: hub_billing_checkout_invoice_confirm_title + translation: "Bestellübersicht" +- id: hub_billing_checkout_invoice_confirm_unit_price + translation: "Preis pro Seat" +- id: hub_billing_checkout_invoice_quantity_decrease + translation: "Anzahl der Seats verringern" +- id: hub_billing_checkout_invoice_quantity_increase + translation: "Anzahl der Seats erhöhen" +- id: hub_billing_checkout_invoice_price_loading + translation: "Preis wird geladen …" +- id: hub_billing_checkout_invoice_price_error + translation: "Der Preis konnte nicht geladen werden." +- id: hub_billing_checkout_invoice_price_retry + translation: "Erneut versuchen" + +- id: hub_billing_checkout_success_description + translation: "Vielen Dank für deinen Kauf! Dein Abonnement ist jetzt aktiv." +- id: hub_billing_checkout_success_invoice_description + translation: "Eine Rechnung wird ausgestellt und an deine E-Mail-Adresse gesendet." +- id: hub_billing_checkout_success_relicense_description + translation: "Um deine Lizenz zu erhalten, kehre bitte zu deiner Hub-Instanz zurück und starte den Abonnementvorgang erneut." + - id: hub_billing_checkout_community_title translation: "Community" - id: hub_billing_checkout_community_statement @@ -677,10 +743,6 @@ - id: hub_billing_checkout_enterprise_action translation: "Kontaktiere uns" -- id: hub_billing_manualinvoice_description - translation: "Um dein Abonnement zu verwalten, wende dich bitte an unser Support-Team." -- id: hub_billing_manualinvoice_action - translation: "Kontaktiere uns" # Hub Demo - id: hub_demo_description diff --git a/i18n/en.yaml b/i18n/en.yaml index 5bff01f76..774533a74 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -34,8 +34,6 @@ translation: "I agree to get updates from Cryptomator Hub and accept the Privacy Policy." - id: accept_hub_newsletter_optional translation: "I agree to get updates from Cryptomator Hub via email (optional)." -- id: accept_hub_managed_early_access_optional - translation: "Early Access Try out Cryptomator Hub 2.0.0 with brand new features and an extended 60-day free trial (optional)." - id: accept_privacy translation: "I accept the Privacy Policy." - id: accept_privacy_implicitly @@ -408,10 +406,6 @@ translation: "Sign Up Now" - id: hub_header_managed_cta_description translation: "Start your free 30-day trial today, no credit card required." -- id: hub_header_kicker_early_access - translation: "Hub 2.0.0 Early Access*" -- id: hub_header_early_access_note - translation: "Early access includes brand new features and an extended 60-day free trial." - id: hub_header_group_magic_alt translation: "Cryptobot connects a group of people with its magical abilities through zero-knowledge encryption" @@ -540,12 +534,13 @@ - 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_loadfailed_description + translation: "We couldn't load your subscription information. Please contact us if the problem persists." + - id: hub_billing_manage_status_title translation: "Status" - id: hub_billing_manage_status_active translation: "Active" -- id: hub_billing_manage_status_pastdue - translation: "Past Due" - id: hub_billing_manage_status_trialing translation: "Trialing" - id: hub_billing_manage_status_paused @@ -568,12 +563,10 @@ - id: hub_billing_manage_payment_info_title translation: "Payment Information" -- id: hub_billing_manage_payment_info_credit_card - translation: "Credit Card" -- id: hub_billing_manage_payment_info_credit_card_last_four_digits_description - translation: "Ending with" -- id: hub_billing_manage_payment_info_paypal - translation: "PayPal" +- id: hub_billing_manage_payment_info_card + translation: "Card / PayPal" +- id: hub_billing_manage_payment_info_invoice + translation: "Invoice" - id: hub_billing_manage_payment_info_update_action translation: "Update Payment Method" @@ -586,8 +579,6 @@ - id: hub_billing_manage_license_key_retry_action translation: "Retry" -- id: hub_billing_manage_modal_charge_amount_description - translation: "Charge Amount" - id: hub_billing_manage_modal_continue translation: "Continue" - id: hub_billing_manage_modal_confirm @@ -610,8 +601,22 @@ translation: "New Number of Seats" - id: hub_billing_manage_change_quantity_confirmation_increase_warning translation: "You are about to increase the seats limit. By confirming, you will be immediately charged for the difference." +- id: hub_billing_manage_change_quantity_confirmation_increase_warning_invoice + translation: "You are about to increase the seats limit. By confirming, the additional seats will be billed by invoice." - id: hub_billing_manage_change_quantity_confirmation_decrease_warning translation: "You are about to decrease the seats limit. By confirming, your next payment will be reduced by the difference." +- id: hub_billing_manage_change_quantity_confirmation_decrease_warning_invoice + translation: "You are about to decrease the seats limit. By confirming, the reduction will be reflected on your next invoice." +- id: hub_billing_manage_change_quantity_confirmation_prorated_invoice + translation: "Amount invoiced now" +- id: hub_billing_manage_change_quantity_confirmation_credited_invoice + translation: "Credit added to your account" +- id: hub_billing_manage_change_quantity_confirmation_incl_vat + translation: "incl. {rate}% VAT" +- id: hub_billing_manage_change_quantity_confirmation_available_credit_invoice + translation: "Available credit (applied to a future invoice, not refunded)" +- id: hub_billing_manage_change_quantity_confirmation_new_recurring_invoice + translation: "New yearly total (net)" - id: hub_billing_checkout_description translation: "Unlock the full potential of your Hub instance and get your team on board with client-side encryption for your cloud storage." @@ -643,13 +648,74 @@ translation: "Only users who are granted access to vaults count towards the seats limit." - id: hub_billing_checkout_standard_email translation: "Email" -- id: hub_billing_checkout_standard_email_placeholder - translation: "Email address" - id: hub_billing_checkout_standard_instruction translation: "Payments are securely handled by Paddle." - id: hub_billing_checkout_standard_submit translation: "Checkout" +- id: hub_billing_checkout_payment_method + translation: "Payment Method" +- id: hub_billing_checkout_payment_method_card + translation: "Pay by Card" +- id: hub_billing_checkout_payment_method_invoice + translation: "Pay by Invoice" + +- id: hub_billing_checkout_invoice_email + translation: "Email" +- id: hub_billing_checkout_invoice_email_hint + translation: "You'll use this email address to manage your subscription later." +- id: hub_billing_checkout_invoice_account_name + translation: "Company Name" +- id: hub_billing_checkout_invoice_address_street + translation: "Street and Number" +- id: hub_billing_checkout_invoice_address_postal_code + translation: "Postal Code" +- id: hub_billing_checkout_invoice_address_city + translation: "City" +- id: hub_billing_checkout_invoice_address_country + translation: "Country" +- id: hub_billing_checkout_invoice_address_country_placeholder + translation: "Please select" +- id: hub_billing_checkout_invoice_non_eu_hint + translation: "Invoice payment is available within the EU only. Outside the EU? Please contact us via the Enterprise option." +- id: hub_billing_checkout_invoice_vat_id + translation: "VAT ID" +- id: hub_billing_checkout_invoice_details_on_file + translation: "These are the billing details we have on file for your email address. Contact us if they need to change." +- id: hub_billing_checkout_invoice_vat_id_hint + translation: "Required for EU countries outside Germany." +- id: hub_billing_checkout_invoice_instruction + translation: "An invoice will be issued and sent to your email address." +- id: hub_billing_checkout_invoice_submit + translation: "Buy on Invoice" +- id: hub_billing_checkout_invoice_total_suffix + translation: "per year (net)" +- id: hub_billing_checkout_invoice_total_vat_hint + translation: "plus 19% German VAT" +- id: hub_billing_checkout_invoice_total_reverse_charge_hint + translation: "reverse charge – VAT to be accounted for by the recipient" +- id: hub_billing_checkout_invoice_confirm_title + translation: "Order Summary" +- id: hub_billing_checkout_invoice_confirm_unit_price + translation: "Price per Seat" +- id: hub_billing_checkout_invoice_quantity_decrease + translation: "Decrease number of seats" +- id: hub_billing_checkout_invoice_quantity_increase + translation: "Increase number of seats" +- id: hub_billing_checkout_invoice_price_loading + translation: "Loading price…" +- id: hub_billing_checkout_invoice_price_error + translation: "Loading the price failed." +- id: hub_billing_checkout_invoice_price_retry + translation: "Try again" + +- id: hub_billing_checkout_success_description + translation: "Thank you for your purchase! Your subscription is now active." +- id: hub_billing_checkout_success_invoice_description + translation: "An invoice will be issued and sent to your email address." +- id: hub_billing_checkout_success_relicense_description + translation: "To receive your license, please return to your Hub instance and start the subscription process again." + - id: hub_billing_checkout_community_title translation: "Community" - id: hub_billing_checkout_community_statement @@ -677,10 +743,6 @@ - id: hub_billing_checkout_enterprise_action translation: "Contact Us" -- id: hub_billing_manualinvoice_description - translation: "To manage your subscription, please contact our support team." -- id: hub_billing_manualinvoice_action - translation: "Contact Us" # Hub Demo - id: hub_demo_description diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 8dee484d9..accdfd12f 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -154,6 +154,9 @@ {{ end }} {{ $jquery := resources.Get "js/jquery/jquery.min.js" | fingerprint }} + {{/* plugins must load before the Alpine core so they register on alpine:init */}} + {{ $alpineFocus := resources.Get "js/alpinejs-focus/cdn.min.js" | fingerprint }} + {{ $alpine := resources.Get "js/alpinejs/cdn.min.js" | fingerprint }} {{ $lazysizes := resources.Get "js/lazysizes/lazysizes.min.js" | fingerprint }} diff --git a/layouts/for-teams/single.html b/layouts/for-teams/single.html index 49f66c73e..945611101 100644 --- a/layouts/for-teams/single.html +++ b/layouts/for-teams/single.html @@ -10,14 +10,9 @@ {{ i18n
-
-
- Logo - CRYPTOMATOR HUB -
-
- {{ i18n "hub_header_kicker_early_access" . }} -
+
+ Logo + CRYPTOMATOR HUB

{{ i18n "hub_header_title" . | safeHTML }}

{{ i18n "hub_header_description" . | safeHTML }}

@@ -25,9 +20,6 @@

{{ i18n "hub_header_title" . | safeHTML }}

{{ i18n "hub_header_managed_cta" . }}

{{ i18n "hub_header_managed_cta_description" . | safeHTML }}

-

- * {{ i18n "hub_header_early_access_note" . | safeHTML }} -

diff --git a/layouts/hub-billing/single.html b/layouts/hub-billing/single.html index 5c2b7e5e5..352614c37 100644 --- a/layouts/hub-billing/single.html +++ b/layouts/hub-billing/single.html @@ -3,7 +3,7 @@ {{ end }} {{ define "main" }}
-
+ @@ -113,6 +112,22 @@

+ +