mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge pull request #1080 from appwrite/refactor-org-creation
refactor: org creation new flow
This commit is contained in:
Generated
+7
-4
@@ -12,7 +12,7 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/svelte": "^7.66.0",
|
||||
"@sentry/tracing": "^7.66.0",
|
||||
"@stripe/stripe-js": "^2.2.0",
|
||||
"@stripe/stripe-js": "^3.4.0",
|
||||
"ai": "^2.2.11",
|
||||
"analytics": "^0.8.9",
|
||||
"dayjs": "^1.11.9",
|
||||
@@ -2301,9 +2301,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.4.0.tgz",
|
||||
"integrity": "sha512-WFkQx1mbs2b5+7looI9IV1BLa3bIApuN3ehp9FP58xGg7KL9hCHDECgW3BwO9l9L+xBPVAD7Yjn1EhGe6EDTeA=="
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.4.0.tgz",
|
||||
"integrity": "sha512-a2kUP7OrsV0SSIk3UxWa+cnrW+PPIyuCbWIBH8vxfHIqmyeQN/d0lsplZJ2h7MlLsU/sB3EyhNBkhLLT+zHwKw==",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-static": {
|
||||
"version": "3.0.1",
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/svelte": "^7.66.0",
|
||||
"@sentry/tracing": "^7.66.0",
|
||||
"@stripe/stripe-js": "^2.2.0",
|
||||
"@stripe/stripe-js": "^3.4.0",
|
||||
"ai": "^2.2.11",
|
||||
"analytics": "^0.8.9",
|
||||
"dayjs": "^1.11.9",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Buttons } from '../stores/notifications';
|
||||
import { createEventDispatcher, type ComponentProps } from 'svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
|
||||
export let dismissible = false;
|
||||
export let type: 'info' | 'success' | 'warning' | 'error' | 'default' = 'info';
|
||||
export let buttons: Buttons[] = [];
|
||||
export let buttons: (ComponentProps<Button> & {
|
||||
slot: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
})[] = [];
|
||||
export let isAction = false;
|
||||
export let isStandalone = false;
|
||||
let classes = '';
|
||||
@@ -53,8 +55,8 @@
|
||||
<div class="alert-buttons u-flex">
|
||||
<slot name="buttons">
|
||||
{#each buttons as button}
|
||||
<Button text on:click={button.method}>
|
||||
<span class="text">{button.name}</span>
|
||||
<Button text {...button} on:click={button.onClick}>
|
||||
<span class="text">{button.slot}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</slot>
|
||||
@@ -66,9 +68,9 @@
|
||||
<div class="alert-buttons u-flex u-gap-16 u-cross-child-center">
|
||||
<slot name="buttons">
|
||||
{#each buttons as button}
|
||||
<button type="button" class="button is-text" on:click={button.method}>
|
||||
<span class="text">{button.name}</span>
|
||||
</button>
|
||||
<Button text {...button} on:click={button.onClick}>
|
||||
+ <span class="text">{button.slot}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { readOnly, tierToPlan } from '$lib/stores/billing';
|
||||
import { hideBillingHeaderRoutes, readOnly, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
</script>
|
||||
|
||||
{#if $organization?.$id && $organization?.billingPlan === BillingPlan.STARTER && $readOnly && !$page.url.pathname.includes('/console/account')}
|
||||
{#if $organization?.$id && $organization?.billingPlan === BillingPlan.STARTER && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
<HeaderAlert
|
||||
type="error"
|
||||
title={`${$organization.name} usage has reached the ${tierToPlan($organization.billingPlan).name} plan limit`}>
|
||||
@@ -21,14 +19,14 @@
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button
|
||||
href={`${base}/console/organization-${$organization?.$id}/usage`}
|
||||
href={`${base}/console/organization-${$organization.$id}/usage`}
|
||||
text
|
||||
fullWidthMobile>
|
||||
<span class="text">View usage</span>
|
||||
</Button>
|
||||
<Button
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'limit_reached_banner'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { hideBillingHeaderRoutes } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
</script>
|
||||
|
||||
{#if $organization?.markedForDeletion && !$page.url.pathname.includes('/console/account')}
|
||||
{#if $organization?.markedForDeletion && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
<HeaderAlert title="Organization flagged for deletion">
|
||||
<svelte:fragment>
|
||||
All existing projects in the {$organization.name} organization have been paused. This organization
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { hideBillingHeaderRoutes } from '$lib/stores/billing';
|
||||
import { orgMissingPaymentMethod } from '$routes/console/store';
|
||||
</script>
|
||||
|
||||
{#if ($orgMissingPaymentMethod.billingPlan === BillingPlan.PRO || $orgMissingPaymentMethod.billingPlan === BillingPlan.SCALE) && !$orgMissingPaymentMethod.paymentMethodId && !$orgMissingPaymentMethod.backupPaymentMethodId && !$page.url.pathname.includes('/console/account')}
|
||||
{#if ($orgMissingPaymentMethod.billingPlan === BillingPlan.PRO || $orgMissingPaymentMethod.billingPlan === BillingPlan.SCALE) && !$orgMissingPaymentMethod.paymentMethodId && !$orgMissingPaymentMethod.backupPaymentMethodId && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
<HeaderAlert
|
||||
type="error"
|
||||
title={`Payment method required for ${$orgMissingPaymentMethod.name}`}>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { actionRequiredInvoices } from '$lib/stores/billing';
|
||||
import { actionRequiredInvoices, hideBillingHeaderRoutes } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { VARS } from '$lib/system';
|
||||
|
||||
const endpoint = VARS.APPWRITE_ENDPOINT ?? `${$page.url.origin}/v1`;
|
||||
</script>
|
||||
|
||||
{#if $actionRequiredInvoices && $actionRequiredInvoices?.invoices?.length && !$page.url.pathname.includes('/console/account')}
|
||||
{#if $actionRequiredInvoices && $actionRequiredInvoices?.invoices?.length && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
<HeaderAlert title="Authorization required" type="error">
|
||||
Please authorize your upcoming payment for {$organization.name}. Your bank requires this
|
||||
security measure to proceed with payment.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { paymentMissingMandate } from '$lib/stores/billing';
|
||||
import { hideBillingHeaderRoutes, paymentMissingMandate } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { confirmSetup } from '$lib/stores/stripe';
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $paymentMissingMandate && $paymentMissingMandate.country === 'in' && $paymentMissingMandate.mandateId === null && !$page.url.pathname.includes('/console/account')}
|
||||
{#if $paymentMissingMandate && $paymentMissingMandate.country === 'in' && $paymentMissingMandate.mandateId === null && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
<HeaderAlert title="Authorization required" type="info">
|
||||
The payment method for {$organization.name} needs to be verified.
|
||||
<svelte:fragment slot="buttons">
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { diffDays, toLocaleDate } from '$lib/helpers/date';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { failedInvoice } from '$lib/stores/billing';
|
||||
import { failedInvoice, hideBillingHeaderRoutes } from '$lib/stores/billing';
|
||||
</script>
|
||||
|
||||
{#if $failedInvoice && !$page.url.pathname.includes('/console/account')}
|
||||
{#if $failedInvoice && !hideBillingHeaderRoutes.includes($page.url.pathname)}
|
||||
{@const daysPassed = diffDays(new Date($failedInvoice.dueAt), new Date())}
|
||||
<HeaderAlert title="Your projects are at risk">
|
||||
<svelte:fragment>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { tierToPlan } from '$lib/stores/billing';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { Card } from '..';
|
||||
|
||||
export let service: string;
|
||||
@@ -23,12 +21,12 @@
|
||||
class="u-margin-block-start-16"
|
||||
secondary
|
||||
fullWidthMobile
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: eventSource
|
||||
});
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
}}>
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { FormList, InputChoice, InputNumber } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { plansInfo, type Tier } from '$lib/stores/billing';
|
||||
|
||||
export let billingPlan: Tier;
|
||||
export let collaborators: string[];
|
||||
export let couponData: Partial<Coupon>;
|
||||
export let billingBudget: number;
|
||||
|
||||
const today = new Date();
|
||||
const billingPayDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let budgetEnabled = false;
|
||||
|
||||
$: currentPlan = $plansInfo.get(billingPlan);
|
||||
$: extraSeatsCost = (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
|
||||
$: grossCost = currentPlan.price + extraSeatsCost;
|
||||
$: estimatedTotal =
|
||||
couponData?.status === 'active'
|
||||
? grossCost - couponData.credits >= 0
|
||||
? grossCost - couponData.credits
|
||||
: 0
|
||||
: grossCost;
|
||||
$: trialEndDate = new Date(
|
||||
billingPayDate.getTime() + currentPlan.trialDays * 24 * 60 * 60 * 1000
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="card u-margin-block-start-32 u-flex u-flex-vertical u-gap-8">
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">{currentPlan.name} plan</p>
|
||||
<p class="text">{formatCurrency(currentPlan.price)}</p>
|
||||
</span>
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Additional seats ({collaborators?.length})</p>
|
||||
<p class="text">
|
||||
{formatCurrency(extraSeatsCost)}
|
||||
</p>
|
||||
</span>
|
||||
{#if couponData?.status === 'active'}
|
||||
<span class="u-flex u-main-space-between">
|
||||
<div class="u-flex u-cross-center u-gap-4">
|
||||
<p class="text">
|
||||
<span class="icon-tag u-color-text-success" aria-hidden="true" />
|
||||
{#if couponData.credits > 100}
|
||||
{couponData.code.toUpperCase()}
|
||||
{:else}
|
||||
<span use:tooltip={{ content: couponData.code.toUpperCase() }}
|
||||
>Credits applied</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
style="--button-size:1.5rem;"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
on:click={() =>
|
||||
(couponData = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
})}>
|
||||
<span class="icon-x" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{#if couponData.credits > 100}
|
||||
<p class="inline-tag" use:tooltip={{ content: formatCurrency(couponData.credits) }}>
|
||||
Credits applied
|
||||
</p>
|
||||
{:else}
|
||||
<span class="u-color-text-success">-{formatCurrency(couponData.credits)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="u-sep-block-start" />
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Estimated total</p>
|
||||
<p class="text">
|
||||
{formatCurrency(estimatedTotal)}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<p class="text u-margin-block-start-16">
|
||||
Your payment method will be charged this amount plus usage fees every 30 days {!currentPlan.trialDays
|
||||
? `starting ${toLocaleDate(billingPayDate.toString())}`
|
||||
: ` after your trial period ends on ${toLocaleDate(trialEndDate.toString())}`}.
|
||||
</p>
|
||||
<FormList class="u-margin-block-start-24">
|
||||
<InputChoice
|
||||
type="switchbox"
|
||||
id="budget"
|
||||
label="Enable budget cap"
|
||||
tooltip="If enabled, you will be notified when your spending reaches 75% of the set cap. Update cap alerts in your organization settings."
|
||||
fullWidth
|
||||
bind:value={budgetEnabled}>
|
||||
{#if budgetEnabled}
|
||||
<div class="u-margin-block-start-16">
|
||||
<InputNumber
|
||||
id="budget"
|
||||
label="Budget cap (USD)"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
bind:value={billingBudget} />
|
||||
</div>
|
||||
{/if}
|
||||
</InputChoice>
|
||||
</FormList>
|
||||
</section>
|
||||
@@ -1,3 +1,7 @@
|
||||
export { default as PaymentBoxes } from './paymentBoxes.svelte';
|
||||
export { default as CouponInput } from './couponInput.svelte';
|
||||
export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
|
||||
export { default as UsageRates } from './usageRates.svelte';
|
||||
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
|
||||
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
|
||||
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
|
||||
|
||||
+9
-2
@@ -68,8 +68,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<FakeModal bind:show title="Add payment method" bind:error onSubmit={handleSubmit}>
|
||||
<FakeModal
|
||||
bind:show
|
||||
title="Add payment method"
|
||||
bind:error
|
||||
onSubmit={handleSubmit}
|
||||
headerDivider={false}>
|
||||
<FormList gap={16}>
|
||||
<slot />
|
||||
<InputText
|
||||
id="name"
|
||||
label="Cardholder name"
|
||||
@@ -86,10 +92,11 @@
|
||||
<!-- Stripe will create form elements here -->
|
||||
</div>
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
</FormList>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button submit disabled={!name}>Save</Button>
|
||||
<Button submit disabled={!name}>Add</Button>
|
||||
</svelte:fragment>
|
||||
</FakeModal>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { Box, SecondaryTabs, SecondaryTabsItem } from '..';
|
||||
|
||||
let selectedTab: 'tier-0' | 'tier-1' = 'tier-0';
|
||||
|
||||
$: plan = $plansInfo.get(selectedTab);
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<SecondaryTabs stretch>
|
||||
<SecondaryTabsItem
|
||||
disabled={selectedTab === 'tier-0'}
|
||||
on:click={() => (selectedTab = 'tier-0')}>
|
||||
Starter
|
||||
</SecondaryTabsItem>
|
||||
<SecondaryTabsItem
|
||||
disabled={selectedTab === 'tier-1'}
|
||||
on:click={() => (selectedTab = 'tier-1')}>
|
||||
Pro
|
||||
</SecondaryTabsItem>
|
||||
</SecondaryTabs>
|
||||
|
||||
<div class="u-margin-block-start-24">
|
||||
{#if selectedTab === 'tier-0'}
|
||||
<h3 class="u-bold body-text-1">{plan.name} plan</h3>
|
||||
<ul class="un-order-list u-margin-block-start-8">
|
||||
<li>
|
||||
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions} Functions
|
||||
per project
|
||||
</li>
|
||||
<li>Limited to 1 organization member</li>
|
||||
<li>{plan.bandwidth}GB bandwidth</li>
|
||||
<li>{plan.storage}GB storage</li>
|
||||
<li>{formatNum(plan.executions)} executions</li>
|
||||
</ul>
|
||||
{:else if selectedTab === 'tier-1'}
|
||||
<h3 class="u-bold body-text-1">{plan.name} plan</h3>
|
||||
<ul class="un-order-list u-margin-block-start-8">
|
||||
<li>Everything in the Starter plan, plus:</li>
|
||||
<li>Unlimited databases, buckets, functions</li>
|
||||
<li>{plan.bandwidth}GB bandwidth</li>
|
||||
<li>{plan.storage}GB storage</li>
|
||||
<li>{formatNum(plan.executions)} executions</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { Button, InputChoice, InputSelectSearch, InputText } from '$lib/elements/forms';
|
||||
import type { PaymentList, PaymentMethodData } from '$lib/sdk/billing';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { hasStripePublicKey, isCloud } from '$lib/system';
|
||||
import { onMount } from 'svelte';
|
||||
import { Alert, Card, CreditCardBrandImage } from '..';
|
||||
import PaymentModal from './paymentModal.svelte';
|
||||
import { capitalize } from '$lib/helpers/string';
|
||||
|
||||
export let methods: PaymentList;
|
||||
export let value: string;
|
||||
export let taxId = '';
|
||||
|
||||
let showTaxId = false;
|
||||
let showPaymentModal = false;
|
||||
|
||||
async function cardSaved(event: CustomEvent<PaymentMethodData>) {
|
||||
value = event.detail.$id;
|
||||
methods = await sdk.forConsole.billing.listPaymentMethods();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (methods?.total && methods.paymentMethods.some((method) => !!method?.last4)) {
|
||||
value = methods.paymentMethods[0].$id;
|
||||
}
|
||||
});
|
||||
|
||||
$: filteredMethods = methods?.paymentMethods?.filter((method) => !!method?.last4);
|
||||
|
||||
$: selectedPaymentMethod = methods?.paymentMethods?.find((method) => method.$id === value);
|
||||
</script>
|
||||
|
||||
{#if filteredMethods?.length}
|
||||
{#if selectedPaymentMethod?.country === 'in'}
|
||||
<Alert type="warning">
|
||||
<svelte:fragment slot="title">Indian credit or debit card-holders</svelte:fragment>
|
||||
To comply with RBI regulations in India, Appwrite will ask for verification to charge up
|
||||
to $150 USD on your payment method. We will never charge more than the cost of your plan
|
||||
and the resources you use, or your budget cap limit. For higher usage limits, please contact
|
||||
us.
|
||||
</Alert>
|
||||
{/if}
|
||||
<InputSelectSearch
|
||||
id="method"
|
||||
label="Payment method"
|
||||
placeholder="Select payment method"
|
||||
bind:value
|
||||
options={filteredMethods.map((method) => {
|
||||
return {
|
||||
value: method.$id,
|
||||
label: `${capitalize(method.brand)} ending in ${method.last4}`,
|
||||
data: [method.brand]
|
||||
};
|
||||
})}
|
||||
interactiveOutput
|
||||
let:option={o}>
|
||||
<svelte:fragment slot="output" let:option={o}>
|
||||
<output class="input-text">
|
||||
<span class="u-flex u-gap-16 u-flex-vertical">
|
||||
<span class="u-flex u-gap-16">
|
||||
<span class="u-flex u-cross-center u-gap-8" style="padding-inline:0.25rem">
|
||||
<span>{o.label}</span>
|
||||
<CreditCardBrandImage brand={o.data?.toString()} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</output>
|
||||
</svelte:fragment>
|
||||
|
||||
<span class="u-flex u-gap-16 u-flex-vertical">
|
||||
<span class="u-flex u-gap-16">
|
||||
<span class="u-flex u-cross-center u-gap-8" style="padding-inline:0.25rem">
|
||||
<span>{o.label}</span>
|
||||
<CreditCardBrandImage brand={o.data?.toString()} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<svelte:fragment slot="listEnd">
|
||||
<Button text on:click={() => (showPaymentModal = true)}>
|
||||
<span class="icon-plus"></span>
|
||||
<span class="text">Add new payment method</span>
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</InputSelectSearch>
|
||||
{:else}
|
||||
<Card
|
||||
isDashed
|
||||
style="--p-card-padding:1rem; --p-card-bg-color: transparent; --p-card-border-radius: 0.5rem"
|
||||
isTile>
|
||||
<div class="u-flex u-main-space-between u-cross-center">
|
||||
<p>
|
||||
<span class="icon-exclamation-circle"></span>
|
||||
<span class="text">No saved payment methods</span>
|
||||
</p>
|
||||
<Button secondary on:click={() => (showPaymentModal = true)}>
|
||||
<span class="icon-plus"></span> <span class="text">Add</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if showPaymentModal && isCloud && hasStripePublicKey}
|
||||
<PaymentModal bind:show={showPaymentModal} on:submit={cardSaved}>
|
||||
<svelte:fragment slot="end">
|
||||
<InputChoice
|
||||
type="checkbox"
|
||||
id="taxIdCheck"
|
||||
label="I'm purchasing as a business"
|
||||
fullWidth
|
||||
bind:value={showTaxId}>
|
||||
{#if showTaxId}
|
||||
<div class="u-margin-block-start-8">
|
||||
<InputText
|
||||
id="taxId"
|
||||
label="Tax ID"
|
||||
placeholder="Tax ID"
|
||||
bind:value={taxId} />
|
||||
</div>
|
||||
{/if}
|
||||
</InputChoice>
|
||||
</svelte:fragment>
|
||||
</PaymentModal>
|
||||
{/if}
|
||||
+8
-9
@@ -10,18 +10,17 @@
|
||||
TableRow
|
||||
} from '$lib/elements/table';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { createOrganization } from './store';
|
||||
import { plansInfo, type Tier } from '$lib/stores/billing';
|
||||
import { organization, type Organization } from '$lib/stores/organization';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { abbreviateNumber, formatCurrency } from '$lib/helpers/numbers';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
|
||||
export let show = false;
|
||||
export let tier: Tier;
|
||||
export let org: Organization;
|
||||
|
||||
$: plan = $plansInfo?.get(tier);
|
||||
$: plan = $plansInfo?.get(org.billingPlan);
|
||||
|
||||
$: nextDate = $createOrganization?.name
|
||||
$: nextDate = org?.name
|
||||
? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toString()
|
||||
: $organization?.billingNextInvoiceDate;
|
||||
|
||||
@@ -50,7 +49,7 @@
|
||||
}
|
||||
];
|
||||
|
||||
$: isFree = tier === BillingPlan.STARTER;
|
||||
$: isFree = org.billingPlan === BillingPlan.STARTER;
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="big" headerDivider={false} title="Usage rates">
|
||||
@@ -58,12 +57,12 @@
|
||||
Usage on the Starter plan is limited for the following resources. Next billing period: {toLocaleDate(
|
||||
nextDate
|
||||
)}.
|
||||
{:else if tier === BillingPlan.PRO}
|
||||
{:else if org.billingPlan === BillingPlan.PRO}
|
||||
<p>
|
||||
Usage on the Pro plan will be charged at the end of each billing period at the following
|
||||
rates. Next billing period: {toLocaleDate(nextDate)}.
|
||||
</p>
|
||||
{:else if tier === BillingPlan.SCALE}
|
||||
{:else if org.billingPlan === BillingPlan.SCALE}
|
||||
<p>
|
||||
Usage on the Scale plan will be charged at the end of each billing period at the
|
||||
following rates. Next billing period: {toLocaleDate(nextDate)}.
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from '$lib/components';
|
||||
import { Button, FormList, InputText } from '$lib/elements/forms';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let show = false;
|
||||
let error: string = null;
|
||||
let coupon: string = '';
|
||||
export let couponData: Partial<Coupon> = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
|
||||
async function addCoupon() {
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
couponData = response;
|
||||
dispatch('validation', couponData);
|
||||
coupon = null;
|
||||
show = false;
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (coupon) {
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:show
|
||||
title="Add credits"
|
||||
headerDivider={false}
|
||||
onSubmit={addCoupon}
|
||||
size="big"
|
||||
bind:error>
|
||||
Credits will be applied automatically to your next invoice.
|
||||
|
||||
<FormList>
|
||||
<InputText placeholder="Promo code" id="code" label="Add promo code" bind:value={coupon} />
|
||||
</FormList>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button submit>Add</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -5,6 +5,8 @@
|
||||
isTile?: boolean;
|
||||
isDashed?: boolean;
|
||||
danger?: boolean;
|
||||
style?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
@@ -26,6 +28,7 @@
|
||||
export let href: string = null;
|
||||
let classes = '';
|
||||
export { classes as class };
|
||||
export let style: string = '';
|
||||
|
||||
function getElement() {
|
||||
switch (true) {
|
||||
@@ -46,6 +49,8 @@
|
||||
class:is-border-dashed={isDashed}
|
||||
class:is-danger={danger}
|
||||
class:is-allowed-focus={href}
|
||||
{...$$restProps}
|
||||
{style}
|
||||
on:click
|
||||
on:keyup={clickOnEnter}
|
||||
role={href || isButton ? 'button' : 'generic'}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
|
||||
export let service: string;
|
||||
</script>
|
||||
@@ -9,8 +8,6 @@
|
||||
<article class="card u-grid u-cross-center u-width-full-line">
|
||||
<div class="u-flex u-flex-vertical u-gap-24 u-main-center u-cross-center">
|
||||
<p class="text u-text-center">Upgrade your plan to add more {service}</p>
|
||||
<Button secondary on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
Change plan
|
||||
</Button>
|
||||
<Button secondary href={$upgradeURL}>Change plan</Button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
export let large = false;
|
||||
export let stretch = false;
|
||||
let classes: string = undefined;
|
||||
export { classes as class };
|
||||
</script>
|
||||
|
||||
<ul class="secondary-tabs {classes}" class:is-large={large}>
|
||||
<ul class="secondary-tabs {classes}" class:is-large={large} class:is-stretch={stretch}>
|
||||
<slot />
|
||||
</ul>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
import { isCloud } from '$lib/system';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { localeTimezoneName, utcHourToLocaleHour } from '$lib/helpers/date';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
|
||||
export let show = false;
|
||||
|
||||
@@ -33,9 +33,8 @@
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button
|
||||
fullWidth
|
||||
external
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'support_menu'
|
||||
|
||||
@@ -351,3 +351,38 @@ export enum BillingPlan {
|
||||
PRO = 'tier-1',
|
||||
SCALE = 'tier-2'
|
||||
}
|
||||
|
||||
export const feedbackDowngradeOptions = [
|
||||
{
|
||||
value: 'availableFeatures',
|
||||
label: "The available features don't meet my needs"
|
||||
},
|
||||
{
|
||||
value: 'traction',
|
||||
label: "My project isn't getting traction"
|
||||
},
|
||||
{
|
||||
value: 'bugs',
|
||||
label: 'I experienced bugs or unexpected outages while using the console'
|
||||
},
|
||||
{
|
||||
value: 'starter',
|
||||
label: 'The Starter plan is enough for my projects'
|
||||
},
|
||||
{
|
||||
value: 'budget',
|
||||
label: "I don't have the budget"
|
||||
},
|
||||
{
|
||||
value: 'tryOut',
|
||||
label: 'I just wanted to try it out'
|
||||
},
|
||||
{
|
||||
value: 'alternative',
|
||||
label: 'I found an alternative/competitor to meet my needs'
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: 'Other'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -17,10 +17,19 @@
|
||||
let classes = '';
|
||||
export { classes as class };
|
||||
|
||||
const { isSubmitting } = setContext<FormContext>('form', {
|
||||
let form: HTMLFormElement;
|
||||
|
||||
export let { isSubmitting } = setContext<FormContext>('form', {
|
||||
isSubmitting: writable(false)
|
||||
});
|
||||
|
||||
export function checkValidity() {
|
||||
return form.checkValidity();
|
||||
}
|
||||
export function triggerSubmit() {
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
isSubmitting.set(true);
|
||||
await onSubmit(e);
|
||||
@@ -29,6 +38,7 @@
|
||||
</script>
|
||||
|
||||
<form
|
||||
bind:this={form}
|
||||
class={classes}
|
||||
class:form={!noStyle}
|
||||
class:common-section={!noMargin}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tooltip as tooltipAction } from '$lib/actions/tooltip';
|
||||
import { FormItem, Helper } from '.';
|
||||
|
||||
export let type: 'checkbox' | 'switchbox' = 'checkbox';
|
||||
@@ -51,16 +52,15 @@
|
||||
</h6>
|
||||
{/if}
|
||||
{#if tooltip}
|
||||
<button type="button" class="tooltip" aria-label="variables info">
|
||||
<button
|
||||
type="button"
|
||||
class="tooltip"
|
||||
aria-label="variables info"
|
||||
use:tooltipAction={{ content: tooltip }}>
|
||||
<span
|
||||
class="icon-info"
|
||||
aria-hidden="true"
|
||||
style="font-size: var(--icon-size-small)" />
|
||||
<span class="tooltip-popup" role="tooltip">
|
||||
<p class="text">
|
||||
{tooltip}
|
||||
</p>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
type Option = $$Generic<{
|
||||
value: string | boolean | number;
|
||||
label: string;
|
||||
data?: string[];
|
||||
data?: unknown[];
|
||||
}>;
|
||||
type OptionArray = Option[];
|
||||
|
||||
@@ -193,6 +193,17 @@
|
||||
</li>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="other">
|
||||
{#if $$slots.listEnd}
|
||||
<section class="drop-section">
|
||||
<ul class="drop-list">
|
||||
<li class="drop-list-item">
|
||||
<slot name="listEnd" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</DropList>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
export let readonly = false;
|
||||
export let required = false;
|
||||
export let tooltip: string = null;
|
||||
export let validityRegex: RegExp = null;
|
||||
export let validityMessage: string = null;
|
||||
|
||||
let value = '';
|
||||
let element: HTMLInputElement;
|
||||
@@ -32,8 +34,13 @@
|
||||
if (value === '' && ['Enter', 'Tab'].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['Enter', 'Tab', ' '].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (validityRegex && !validityRegex.test(value)) {
|
||||
error = validityMessage ? validityMessage : 'Invalid value';
|
||||
return;
|
||||
}
|
||||
addValue();
|
||||
}
|
||||
if (['Backspace', 'Delete'].includes(e.key)) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tooltip as tooltipAction } from '$lib/actions/tooltip';
|
||||
|
||||
interface $$Props extends Partial<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
hideRequired?: boolean;
|
||||
@@ -6,6 +8,7 @@
|
||||
hide?: boolean;
|
||||
tooltip?: string;
|
||||
for?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export let required: $$Props['required'] = false;
|
||||
@@ -28,12 +31,12 @@
|
||||
{/if}
|
||||
|
||||
{#if tooltip}
|
||||
<button type="button" on:click|preventDefault class="tooltip" aria-label="input tooltip">
|
||||
<button
|
||||
type="button"
|
||||
on:click|preventDefault
|
||||
class="tooltip"
|
||||
aria-label="input tooltip"
|
||||
use:tooltipAction={{ content: tooltip }}>
|
||||
<span class="icon-info" aria-hidden="true" style="font-size: var(--icon-size-small)" />
|
||||
<span class="tooltip-popup" role="tooltip">
|
||||
<p class="text">
|
||||
{tooltip}
|
||||
</p>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { getServiceLimit, type PlanServices } from '$lib/stores/billing';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { getServiceLimit, upgradeURL, type PlanServices } from '$lib/stores/billing';
|
||||
import { isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { Button } from '../forms';
|
||||
|
||||
let tableBody: HTMLDivElement;
|
||||
@@ -16,8 +15,10 @@
|
||||
let columns = 0;
|
||||
|
||||
const limit = getServiceLimit(service) || Infinity;
|
||||
|
||||
// TODO: refactor this to be a string
|
||||
const upgradeMethod = () => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
goto($upgradeURL);
|
||||
};
|
||||
|
||||
$: limitReached = limit !== 0 && limit < Infinity && total >= limit;
|
||||
@@ -38,7 +39,7 @@
|
||||
<span class="text">Upgrade your plan to add {name} to your organization</span>
|
||||
<Button
|
||||
secondary
|
||||
on:click={upgradeMethod}
|
||||
href={$upgradeURL}
|
||||
on:click={() =>
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
readOnly,
|
||||
showUsageRatesModal,
|
||||
tierToPlan,
|
||||
upgradeURL,
|
||||
type PlanServices
|
||||
} from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { ContainerButton } from '.';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let isFlex = true;
|
||||
export let title: string;
|
||||
@@ -53,9 +53,11 @@
|
||||
];
|
||||
|
||||
const limit = getServiceLimit(serviceId) || Infinity;
|
||||
|
||||
//TODO: refactor this to be a string
|
||||
const upgradeMethod = () => {
|
||||
showDropdown = false;
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
goto($upgradeURL);
|
||||
};
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -100,7 +102,7 @@
|
||||
<span class="text">
|
||||
You've reached the {services} limit for the {tier} plan. <Button
|
||||
link
|
||||
on:click={upgradeMethod}
|
||||
href={$upgradeURL}
|
||||
on:click={() =>
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
@@ -135,7 +137,7 @@
|
||||
{title.toLocaleLowerCase()} per project on the {tier} plan.
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}<Button
|
||||
link
|
||||
on:click={upgradeMethod}
|
||||
href={$upgradeURL}
|
||||
on:click={() =>
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
@@ -157,7 +159,7 @@
|
||||
You are limited to {limit}
|
||||
{title.toLocaleLowerCase()} per organization on the {tier} plan.
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button link on:click={upgradeMethod}>Upgrade</Button>
|
||||
<Button link href={$upgradeURL}>Upgrade</Button>
|
||||
for additional {title.toLocaleLowerCase()}.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import CreateOrganizationCloud from '$routes/console/createOrganizationCloud.svelte';
|
||||
import { Feedback } from '$lib/components/feedback';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
|
||||
let showDropdown = false;
|
||||
let showSupport = false;
|
||||
@@ -57,7 +55,7 @@
|
||||
function createOrg() {
|
||||
showDropdown = false;
|
||||
if (isCloud) {
|
||||
wizard.start(CreateOrganizationCloud);
|
||||
goto(`${base}/console/create-organization`);
|
||||
} else newOrgModal.set(true);
|
||||
}
|
||||
|
||||
@@ -108,8 +106,8 @@
|
||||
{#if isCloud && $organization?.billingPlan === BillingPlan.STARTER && !$page.url.pathname.startsWith('/console/account')}
|
||||
<Button
|
||||
disabled={$organization?.markedForDeletion}
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'top_nav'
|
||||
|
||||
@@ -19,3 +19,7 @@ export { default as GridHeader } from './gridHeader.svelte';
|
||||
export { default as ContainerHeader } from './containerHeader.svelte';
|
||||
export { default as HeaderAlert } from './headerAlert.svelte';
|
||||
export { default as ContainerButton } from './containerButton.svelte';
|
||||
export { default as WizardSecondaryContainer } from './wizardSecondaryContainer.svelte';
|
||||
export { default as WizardSecondaryContent } from './wizardSecondaryContent.svelte';
|
||||
export { default as WizardSecondaryHeader } from './wizardSecondaryHeader.svelte';
|
||||
export { default as WizardSecondaryFooter } from './wizardSecondaryFooter.svelte';
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { Pill } from '$lib/elements';
|
||||
import { isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { getServiceLimit, tierToPlan } from '$lib/stores/billing';
|
||||
import { getServiceLimit, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
@@ -313,12 +311,8 @@
|
||||
Logs are retained in rolling {hoursToDays(limit)} intervals
|
||||
with the {tier} plan.
|
||||
{#if $organization.billingPlan === BillingPlan.STARTER}
|
||||
<Button
|
||||
link
|
||||
on:click={() =>
|
||||
wizard.start(ChangeOrganizationTierCloud)}
|
||||
>Upgrade</Button> to increase your log retention
|
||||
for a longer period.
|
||||
<Button link href={$upgradeURL}>Upgrade</Button> to increase
|
||||
your log retention for a longer period.
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { isMac } from '$lib/helpers/platform';
|
||||
import { slide } from '$lib/helpers/transition';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import Create from '$routes/console/feedbackWizard.svelte';
|
||||
import { showSupportModal } from '$routes/console/wizard/support/store';
|
||||
|
||||
@@ -200,11 +200,9 @@
|
||||
<ul class="drop-list is-not-desktop">
|
||||
{#if isCloud && $organization?.billingPlan !== BillingPlan.SCALE}
|
||||
<li class="drop-list-item">
|
||||
<button
|
||||
class="drop-button"
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
<a class="drop-button" href={$upgradeURL}>
|
||||
<span class="text">Upgrade</span>
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="drop-list-item">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<section class="wizard-secondary c-wizard-position">
|
||||
<div class="wizard-secondary-container">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.c-wizard-position {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="wizard-secondary-content">
|
||||
<div class="wizard-secondary-content-1">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="wizard-secondary-content-sep"></div>
|
||||
<div class="wizard-secondary-content-2">
|
||||
<div class="wizard-secondary-content-sticky">
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="wizard-secondary-options">
|
||||
<div class="wizard-secondary-options-start">
|
||||
<slot name="start" />
|
||||
</div>
|
||||
<div class="wizard-secondary-options-end">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Heading } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
export let href: string;
|
||||
</script>
|
||||
|
||||
<header class="wizard-secondary-header">
|
||||
<div class="u-flex u-main-space-between u-gap-32 u-cross-center">
|
||||
<Heading size={5} tag="h1"><slot /></Heading>
|
||||
<Button text round class="u-margin-block-start-8" ariaLabel="close modal" {href}>
|
||||
<span class="icon-x u-font-size-20" aria-hidden="true"></span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if $$slots.description}
|
||||
<p class="body-text-2"><slot name="description" /></p>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -299,7 +299,7 @@ export class Billing {
|
||||
name: string,
|
||||
billingPlan: string,
|
||||
paymentMethodId: string,
|
||||
billingAddressId: string
|
||||
billingAddressId: string = undefined
|
||||
): Promise<Organization> {
|
||||
const path = `/organizations`;
|
||||
const params = {
|
||||
@@ -356,7 +356,7 @@ export class Billing {
|
||||
organizationId: string,
|
||||
billingPlan: string,
|
||||
paymentMethodId: string,
|
||||
billingAddressId: string
|
||||
billingAddressId: string = undefined
|
||||
): Promise<Organization> {
|
||||
const path = `/organizations/${organizationId}/plan`;
|
||||
const params = {
|
||||
|
||||
@@ -24,8 +24,6 @@ import { BillingPlan } from '$lib/constants';
|
||||
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
|
||||
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
|
||||
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
|
||||
import { wizard } from './wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
export type Tier = 'tier-0' | 'tier-1' | 'tier-2';
|
||||
@@ -114,7 +112,7 @@ export type TierData = {
|
||||
|
||||
export const tierFree: TierData = {
|
||||
name: 'Starter',
|
||||
description: 'For personal, passion projects.'
|
||||
description: 'For personal hobby projects of small scale and students.'
|
||||
};
|
||||
|
||||
export const tierPro: TierData = {
|
||||
@@ -239,7 +237,7 @@ export async function checkForUsageLimit(org: Organization) {
|
||||
{
|
||||
name: 'Upgrade plan',
|
||||
method: () => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
goto(`${base}/console/organization-${org.$id}/change-plan`);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'limit_reached_notification'
|
||||
@@ -364,3 +362,10 @@ export async function checkForMissingPaymentMethod() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const upgradeURL = derived(
|
||||
page,
|
||||
($page) => `${base}/console/organization-${$page.data?.organization?.$id}/change-plan`
|
||||
);
|
||||
|
||||
export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account'];
|
||||
|
||||
@@ -193,8 +193,8 @@
|
||||
isStandalone
|
||||
buttons={[
|
||||
{
|
||||
name: 'Learn more',
|
||||
method() {
|
||||
slot: 'Learn more',
|
||||
onClick() {
|
||||
wizard.updateStep((p) => p - 1);
|
||||
}
|
||||
}
|
||||
@@ -213,8 +213,8 @@
|
||||
isStandalone
|
||||
buttons={[
|
||||
{
|
||||
name: 'Edit credentials',
|
||||
method() {
|
||||
slot: 'Edit credentials',
|
||||
onClick() {
|
||||
wizard.updateStep((p) => p - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,9 @@
|
||||
import { stripe } from '$lib/stores/stripe';
|
||||
import MobileSupportModal from './wizard/support/mobileSupportModal.svelte';
|
||||
import { showSupportModal } from './wizard/support/store';
|
||||
|
||||
import UsageRates from './wizard/cloudOrganization/usageRates.svelte';
|
||||
import { activeHeaderAlert, consoleVariables } from './store';
|
||||
import { headerAlert } from '$lib/stores/headerAlert';
|
||||
import { UsageRates } from '$lib/components/billing';
|
||||
|
||||
function kebabToSentenceCase(str: string) {
|
||||
return str
|
||||
@@ -320,5 +319,5 @@
|
||||
{/if}
|
||||
|
||||
{#if isCloud && $showUsageRatesModal}
|
||||
<UsageRates bind:show={$showUsageRatesModal} tier={$organization?.billingPlan} />
|
||||
<UsageRates bind:show={$showUsageRatesModal} org={$organization} />
|
||||
{/if}
|
||||
|
||||
@@ -20,9 +20,8 @@
|
||||
import { daysLeftInTrial, plansInfo } from '$lib/stores/billing';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import CreateOrganizationCloud from '$routes/console/createOrganizationCloud.svelte';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
let addOrganization = false;
|
||||
@@ -40,7 +39,7 @@
|
||||
|
||||
function createOrg() {
|
||||
if (isCloud) {
|
||||
wizard.start(CreateOrganizationCloud);
|
||||
goto(`${base}/console/create-organization`);
|
||||
} else addOrganization = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import EditPaymentModal from './editPaymentModal.svelte';
|
||||
import DeletePaymentModal from './deletePaymentModal.svelte';
|
||||
import { hasStripePublicKey, isCloud } from '$lib/system';
|
||||
import PaymentModal from './paymentModal.svelte';
|
||||
import PaymentModal from '$lib/components/billing/paymentModal.svelte';
|
||||
|
||||
export let showPayment = false;
|
||||
let showDropdown = [];
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Wizard } from '$lib/layout';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import ChoosePlan from './wizard/cloudOrganizationChangeTier/choosePlan.svelte';
|
||||
import PaymentDetails from './wizard/cloudOrganizationChangeTier/paymentDetails.svelte';
|
||||
import InviteMembers from './wizard/cloudOrganizationChangeTier/inviteMembers.svelte';
|
||||
import UsageExcess from './wizard/cloudOrganizationChangeTier/usageExcess.svelte';
|
||||
import ConfirmDetails from './wizard/cloudOrganizationChangeTier/confirmDetails.svelte';
|
||||
import AddressDetails from './wizard/cloudOrganizationChangeTier/addressDetails.svelte';
|
||||
|
||||
import {
|
||||
changeOrganizationFinalAction,
|
||||
changeOrganizationTier,
|
||||
changeTierSteps,
|
||||
feedbackDowngradeOptions,
|
||||
isUpgrade
|
||||
} from './wizard/cloudOrganizationChangeTier/store';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { page } from '$app/stores';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { tierToPlan } from '$lib/stores/billing';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { VARS } from '$lib/system';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function onFinish() {
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
}
|
||||
|
||||
async function changeTier() {
|
||||
//Downgrade
|
||||
if ($changeOrganizationTier.billingPlan === BillingPlan.STARTER) {
|
||||
try {
|
||||
await sdk.forConsole.billing.updatePlan(
|
||||
$organization.$id,
|
||||
$changeOrganizationTier.billingPlan,
|
||||
$changeOrganizationTier.paymentMethodId,
|
||||
$changeOrganizationTier.billingAddressId
|
||||
);
|
||||
|
||||
await fetch(`${VARS.GROWTH_ENDPOINT}/feedback/billing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: tierToPlan($organization.billingPlan).name,
|
||||
to: tierToPlan($changeOrganizationTier.billingPlan).name,
|
||||
email: $user.email,
|
||||
reason: feedbackDowngradeOptions.find(
|
||||
(option) =>
|
||||
option.value === $changeOrganizationTier.feedbackDowngradeReason
|
||||
)?.label,
|
||||
orgId: $organization.$id,
|
||||
userId: $user.$id,
|
||||
message: $changeOrganizationTier?.feedbackMessage ?? ''
|
||||
})
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
isHtml: true,
|
||||
message: `
|
||||
<b>${$organization.name}</b> has been changed to ${
|
||||
tierToPlan($changeOrganizationTier.billingPlan).name
|
||||
} plan.`
|
||||
});
|
||||
wizard.hide();
|
||||
|
||||
trackEvent(Submit.OrganizationDowngrade, {
|
||||
plan: tierToPlan($changeOrganizationTier.billingPlan)?.name
|
||||
});
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(
|
||||
e,
|
||||
$isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const org = await sdk.forConsole.billing.updatePlan(
|
||||
$organization.$id,
|
||||
$changeOrganizationTier.billingPlan,
|
||||
$changeOrganizationTier.paymentMethodId,
|
||||
$changeOrganizationTier.billingAddressId
|
||||
);
|
||||
|
||||
//Add coupon
|
||||
if ($changeOrganizationTier.couponCode) {
|
||||
await sdk.forConsole.billing.addCredit(
|
||||
org.$id,
|
||||
$changeOrganizationTier.couponCode
|
||||
);
|
||||
trackEvent(Submit.CreditRedeem);
|
||||
}
|
||||
|
||||
//Add budget
|
||||
if ($changeOrganizationTier?.billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(
|
||||
org.$id,
|
||||
$changeOrganizationTier.billingBudget,
|
||||
[75]
|
||||
);
|
||||
}
|
||||
|
||||
//Add collaborators
|
||||
if ($changeOrganizationTier?.collaborators?.length) {
|
||||
$changeOrganizationTier.collaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${$page.url.origin}/console/organization-${org.$id}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//Add tax ID
|
||||
if ($changeOrganizationTier?.taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(
|
||||
org.$id,
|
||||
$changeOrganizationTier.taxId
|
||||
);
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
dispatch('created');
|
||||
|
||||
await goto(`/console/organization-${org.$id}`);
|
||||
if ($isUpgrade) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Your organization has been upgraded'
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
isHtml: true,
|
||||
message: `
|
||||
<b>${$organization.name}</b> will change to ${
|
||||
tierToPlan($changeOrganizationTier.billingPlan).name
|
||||
} plan at the end of the current billing cycle.`
|
||||
});
|
||||
}
|
||||
trackEvent($isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade, {
|
||||
plan: tierToPlan($changeOrganizationTier.billingPlan)?.name
|
||||
});
|
||||
wizard.hide();
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(
|
||||
e,
|
||||
$isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
$changeOrganizationTier = {
|
||||
billingPlan: BillingPlan.PRO,
|
||||
paymentMethodId: null,
|
||||
collaborators: [],
|
||||
billingAddressId: null,
|
||||
taxId: null,
|
||||
feedbackMessage: null
|
||||
};
|
||||
});
|
||||
|
||||
$changeTierSteps.set(1, {
|
||||
label: 'Plans',
|
||||
component: ChoosePlan
|
||||
});
|
||||
$changeTierSteps.set(2, {
|
||||
label: 'Payment',
|
||||
component: PaymentDetails
|
||||
});
|
||||
$changeTierSteps.set(3, {
|
||||
label: 'Address',
|
||||
component: AddressDetails
|
||||
});
|
||||
$changeTierSteps.set(4, {
|
||||
label: 'Members',
|
||||
component: InviteMembers
|
||||
});
|
||||
$changeTierSteps.set(5, {
|
||||
label: 'Usage',
|
||||
component: UsageExcess,
|
||||
disabled: true
|
||||
});
|
||||
$changeTierSteps.set(6, {
|
||||
label: 'Review',
|
||||
component: ConfirmDetails
|
||||
});
|
||||
|
||||
$wizard.finalAction = changeTier;
|
||||
</script>
|
||||
|
||||
<Wizard
|
||||
title="Change plan"
|
||||
steps={$changeTierSteps}
|
||||
finalAction={$changeOrganizationFinalAction}
|
||||
on:exit={onFinish}
|
||||
confirmExit />
|
||||
@@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, invalidate, preloadData } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { LabelCard } from '$lib/components';
|
||||
import {
|
||||
EstimatedTotalBox,
|
||||
PlanComparisonBox,
|
||||
SelectPaymentMethod
|
||||
} from '$lib/components/billing';
|
||||
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Button, Form, FormList, InputTags, InputText, Label } from '$lib/elements/forms';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import {
|
||||
WizardSecondaryContainer,
|
||||
WizardSecondaryContent,
|
||||
WizardSecondaryFooter,
|
||||
WizardSecondaryHeader
|
||||
} from '$lib/layout';
|
||||
import type { Coupon, PaymentList } from '$lib/sdk/billing';
|
||||
import { plansInfo, tierFree, tierPro, tierToPlan } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organizationList, type Organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
$: anyOrgFree = $organizationList.teams?.find(
|
||||
(org) => (org as Organization)?.billingPlan === BillingPlan.STARTER
|
||||
);
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
|
||||
let previousPage: string = `${base}/console`;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
});
|
||||
|
||||
let formComponent: Form;
|
||||
let isSubmitting = writable(false);
|
||||
|
||||
let methods: PaymentList;
|
||||
let name: string;
|
||||
let billingPlan: BillingPlan = BillingPlan.STARTER;
|
||||
let paymentMethodId: string;
|
||||
let collaborators: string[] = [];
|
||||
let couponData: Partial<Coupon> = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
let taxId: string;
|
||||
|
||||
let billingBudget: number;
|
||||
let showCreditModal = false;
|
||||
|
||||
onMount(async () => {
|
||||
if ($page.url.searchParams.has('coupon')) {
|
||||
const coupon = $page.url.searchParams.get('coupon');
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
couponData = response;
|
||||
} catch (e) {
|
||||
couponData = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
}
|
||||
}
|
||||
if ($page.url.searchParams.has('name')) {
|
||||
name = $page.url.searchParams.get('name');
|
||||
}
|
||||
if ($page.url.searchParams.has('plan')) {
|
||||
const plan = $page.url.searchParams.get('plan');
|
||||
if (plan && Object.values(BillingPlan).includes(plan as BillingPlan)) {
|
||||
billingPlan = plan as BillingPlan;
|
||||
}
|
||||
}
|
||||
if (anyOrgFree) {
|
||||
billingPlan = BillingPlan.PRO;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPaymentMethods() {
|
||||
methods = await sdk.forConsole.billing.listPaymentMethods();
|
||||
paymentMethodId = methods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
try {
|
||||
let org: Organization;
|
||||
|
||||
if (billingPlan === BillingPlan.STARTER) {
|
||||
org = await sdk.forConsole.billing.createOrganization(
|
||||
ID.unique(),
|
||||
name,
|
||||
BillingPlan.STARTER,
|
||||
null,
|
||||
null
|
||||
);
|
||||
} else {
|
||||
// Create free organization if coming from onboarding
|
||||
if (previousPage.includes('/console/onboarding') && !anyOrgFree) {
|
||||
await sdk.forConsole.billing.createOrganization(
|
||||
ID.unique(),
|
||||
'Personal Projects',
|
||||
BillingPlan.STARTER,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
org = await sdk.forConsole.billing.createOrganization(
|
||||
ID.unique(),
|
||||
name,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
null
|
||||
);
|
||||
|
||||
//Add budget
|
||||
if (billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
|
||||
}
|
||||
|
||||
//Add coupon
|
||||
if (couponData?.code) {
|
||||
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
|
||||
trackEvent(Submit.CreditRedeem);
|
||||
}
|
||||
|
||||
//Add collaborators
|
||||
if (collaborators?.length) {
|
||||
collaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${$page.url.origin}/console/organization-${org.$id}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Add tax ID
|
||||
if (taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent(Submit.OrganizationCreate, {
|
||||
plan: tierToPlan(billingPlan)?.name,
|
||||
budget_cap_enabled: !!billingBudget,
|
||||
members_invited: collaborators?.length
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await preloadData(`${base}/console/organization-${org.$id}`);
|
||||
await goto(`${base}/console/organization-${org.$id}`);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${name ?? 'Organization'} has been created`
|
||||
});
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(e, Submit.OrganizationCreate);
|
||||
}
|
||||
}
|
||||
|
||||
$: freePlan = $plansInfo.get(BillingPlan.STARTER);
|
||||
$: proPlan = $plansInfo.get(BillingPlan.PRO);
|
||||
$: if (billingPlan === BillingPlan.PRO) {
|
||||
loadPaymentMethods();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Create organization - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<WizardSecondaryContainer>
|
||||
<WizardSecondaryHeader href={previousPage}>Create organization</WizardSecondaryHeader>
|
||||
<WizardSecondaryContent>
|
||||
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
|
||||
<FormList>
|
||||
<InputText
|
||||
bind:value={name}
|
||||
label="Name"
|
||||
placeholder="Enter name"
|
||||
id="name"
|
||||
required />
|
||||
</FormList>
|
||||
<Label class="label u-margin-block-start-16">Select plan</Label>
|
||||
<p class="text">
|
||||
For more details on our plans, visit our
|
||||
<Button href="https://appwrite.io/pricing" external link>pricing page</Button>.
|
||||
</p>
|
||||
<ul
|
||||
class="u-flex u-gap-16 u-margin-block-start-8"
|
||||
style="--p-grid-item-size:16em; --p-grid-item-size-small-screens:16rem; --grid-gap: 1rem;">
|
||||
<li class="u-flex-basis-50-percent">
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
value="tier-0"
|
||||
disabled={!!anyOrgFree}
|
||||
tooltipShow={!!anyOrgFree}
|
||||
tooltipText="You are limited to 1 Free organization per account.">
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierFree.name}
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">{tierFree.description}</p>
|
||||
<p>
|
||||
{formatCurrency(freePlan?.price ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
|
||||
<li class="u-flex-basis-50-percent">
|
||||
<LabelCard name="plan" bind:group={billingPlan} value="tier-1">
|
||||
<svelte:fragment slot="custom">
|
||||
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierPro.name}
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierPro.description}
|
||||
</p>
|
||||
<p>
|
||||
{formatCurrency(proPlan?.price ?? 0)} per member/month + usage
|
||||
</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
</ul>
|
||||
{#if billingPlan === BillingPlan.PRO}
|
||||
<FormList class="u-margin-block-start-16">
|
||||
<InputTags
|
||||
bind:tags={collaborators}
|
||||
label="Invite members by email"
|
||||
tooltip="Invited members will have access to all services and payment data within your organization"
|
||||
placeholder="Enter email address(es)"
|
||||
validityRegex={emailRegex}
|
||||
validityMessage="Invalid email address"
|
||||
id="members" />
|
||||
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId
|
||||
></SelectPaymentMethod>
|
||||
</FormList>
|
||||
{#if !couponData?.code}
|
||||
<Button
|
||||
text
|
||||
noMargin
|
||||
class="u-margin-block-start-16"
|
||||
on:click={() => (showCreditModal = true)}>
|
||||
<span class="icon-plus"></span> <span class="text">Add credits</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Form>
|
||||
<svelte:fragment slot="aside">
|
||||
{#if billingPlan !== BillingPlan.STARTER}
|
||||
<EstimatedTotalBox
|
||||
{billingPlan}
|
||||
{collaborators}
|
||||
bind:couponData
|
||||
bind:billingBudget />
|
||||
{:else}
|
||||
<PlanComparisonBox />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</WizardSecondaryContent>
|
||||
|
||||
<WizardSecondaryFooter>
|
||||
<Button fullWidthMobile href={`${base}/console`} secondary>Cancel</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
disabled={$isSubmitting}>
|
||||
Create organization
|
||||
</Button>
|
||||
</WizardSecondaryFooter>
|
||||
</WizardSecondaryContainer>
|
||||
|
||||
<ValidateCreditModal bind:show={showCreditModal} bind:couponData />
|
||||
@@ -1,149 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Wizard } from '$lib/layout';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import OrganizationDetails from './wizard/cloudOrganization/organizationDetails.svelte';
|
||||
import PaymentDetails from './wizard/cloudOrganization/paymentDetails.svelte';
|
||||
import InviteMembers from './wizard/cloudOrganization/inviteMembers.svelte';
|
||||
import ConfirmDetails from './wizard/cloudOrganization/confirmDetails.svelte';
|
||||
import {
|
||||
createOrganization,
|
||||
createOrganizationFinalAction,
|
||||
createOrgSteps
|
||||
} from './wizard/cloudOrganization/store';
|
||||
import { goto, invalidate, preloadData } from '$app/navigation';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { page } from '$app/stores';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { tierToPlan } from '$lib/stores/billing';
|
||||
import AddressDetails from './wizard/cloudOrganization/addressDetails.svelte';
|
||||
|
||||
async function onFinish() {
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
try {
|
||||
// Create free organization if coming from onboarding
|
||||
if ($page.url.pathname.includes('/console/onboarding')) {
|
||||
await sdk.forConsole.billing.createOrganization(
|
||||
ID.unique(),
|
||||
'Personal Projects',
|
||||
BillingPlan.STARTER,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
const org = await sdk.forConsole.billing.createOrganization(
|
||||
$createOrganization.id ?? ID.unique(),
|
||||
$createOrganization.name,
|
||||
$createOrganization.billingPlan,
|
||||
$createOrganization.paymentMethodId,
|
||||
$createOrganization.billingAddressId
|
||||
);
|
||||
|
||||
//Add budget
|
||||
if ($createOrganization?.billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(
|
||||
org.$id,
|
||||
$createOrganization.billingBudget,
|
||||
[75]
|
||||
);
|
||||
}
|
||||
|
||||
//Add coupon
|
||||
if ($createOrganization?.couponCode) {
|
||||
await sdk.forConsole.billing.addCredit(org.$id, $createOrganization.couponCode);
|
||||
trackEvent(Submit.CreditRedeem);
|
||||
}
|
||||
|
||||
//Add collaborators
|
||||
if ($createOrganization?.collaborators?.length) {
|
||||
$createOrganization.collaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${$page.url.origin}/console/organization-${org.$id}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//Add tax ID
|
||||
if ($createOrganization?.taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(org.$id, $createOrganization.taxId);
|
||||
}
|
||||
|
||||
trackEvent(Submit.OrganizationCreate, {
|
||||
customId: !!$createOrganization.id,
|
||||
plan: tierToPlan($createOrganization.billingPlan)?.name,
|
||||
budget_cap_enabled: !!$createOrganization?.billingBudget,
|
||||
members_invited: $createOrganization?.collaborators?.length
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await preloadData(`/console/organization-${org.$id}`);
|
||||
await goto(`/console/organization-${org.$id}`);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${$createOrganization.name ?? 'Organization'} has been created`
|
||||
});
|
||||
|
||||
wizard.hide();
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(e, Submit.OrganizationCreate);
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
$createOrganization = {
|
||||
id: null,
|
||||
name: null,
|
||||
billingPlan: BillingPlan.PRO,
|
||||
paymentMethodId: null,
|
||||
collaborators: [],
|
||||
billingAddressId: null,
|
||||
taxId: null,
|
||||
couponCode: null
|
||||
};
|
||||
});
|
||||
|
||||
$createOrgSteps.set(1, {
|
||||
label: 'Organization',
|
||||
component: OrganizationDetails
|
||||
});
|
||||
$createOrgSteps.set(2, {
|
||||
label: 'Payment',
|
||||
component: PaymentDetails
|
||||
});
|
||||
$createOrgSteps.set(3, {
|
||||
label: 'Address',
|
||||
component: AddressDetails
|
||||
});
|
||||
$createOrgSteps.set(4, {
|
||||
label: 'Members',
|
||||
component: InviteMembers
|
||||
});
|
||||
$createOrgSteps.set(5, {
|
||||
label: 'Review',
|
||||
component: ConfirmDetails
|
||||
});
|
||||
|
||||
$wizard.finalAction = create;
|
||||
</script>
|
||||
|
||||
<Wizard
|
||||
title="Create organization"
|
||||
steps={$createOrgSteps}
|
||||
finalAction={$createOrganizationFinalAction}
|
||||
on:exit={onFinish}
|
||||
confirmExit />
|
||||
@@ -11,14 +11,12 @@
|
||||
import { Container } from '$lib/layout';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import CreateOrganizationCloud from '../createOrganizationCloud.svelte';
|
||||
import { tierToPlan, type Tier, plansInfo } from '$lib/stores/billing';
|
||||
import { createOrganization } from '../wizard/cloudOrganization/store';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
let name: string;
|
||||
let id: string;
|
||||
@@ -43,7 +41,7 @@
|
||||
if ($page.url.searchParams.has('type')) {
|
||||
const paramType = $page.url.searchParams.get('type');
|
||||
if (paramType === 'createPro') {
|
||||
wizard.start(CreateOrganizationCloud);
|
||||
goto(`${base}/console/create-organization`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,10 +77,7 @@
|
||||
trackError(error, Submit.OrganizationCreate);
|
||||
}
|
||||
} else {
|
||||
wizard.start(CreateOrganizationCloud, null, 2);
|
||||
$createOrganization.name = orgName;
|
||||
$createOrganization.billingPlan = plan;
|
||||
$createOrganization.id = id;
|
||||
goto(`${base}/console/create-organization?name=${orgName}&plan=${plan}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
import { readOnly } from '$lib/stores/billing';
|
||||
import type { RegionList } from '$lib/sdk/billing';
|
||||
import { onMount } from 'svelte';
|
||||
import CreateOrganizationCloud from '../createOrganizationCloud.svelte';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
|
||||
export let data;
|
||||
@@ -125,7 +124,7 @@
|
||||
if ($page.url.searchParams.has('type')) {
|
||||
const paramType = $page.url.searchParams.get('type');
|
||||
if (paramType === 'createPro') {
|
||||
wizard.start(CreateOrganizationCloud);
|
||||
goto(`${base}/console/create-organization`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,18 @@
|
||||
import PaymentHistory from './paymentHistory.svelte';
|
||||
import TaxId from './taxId.svelte';
|
||||
import { Alert, Heading } from '$lib/components';
|
||||
import { failedInvoice, paymentMethods } from '$lib/stores/billing';
|
||||
import { failedInvoice, paymentMethods, upgradeURL } from '$lib/stores/billing';
|
||||
import type { PaymentMethodData } from '$lib/sdk/billing';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { confirmPayment } from '$lib/stores/stripe';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import RetryPaymentModal from './retryPaymentModal.svelte';
|
||||
import { selectedInvoice, showRetryModal } from './store';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -37,7 +36,7 @@
|
||||
onMount(async () => {
|
||||
if ($page.url.searchParams.has('type')) {
|
||||
if ($page.url.searchParams.get('type') === 'upgrade') {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
goto($upgradeURL);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
import AddCreditModal from './addCreditModal.svelte';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
|
||||
let offset = 0;
|
||||
let creditList: CreditList = {
|
||||
@@ -132,8 +132,8 @@
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button
|
||||
secondary
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'billing_add_credits'
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
import { Alert, CardGrid, Heading } from '$lib/components';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Button, Form, FormList, InputNumber, InputSwitch } from '$lib/elements/forms';
|
||||
import { showUsageRatesModal } from '$lib/stores/billing';
|
||||
import { showUsageRatesModal, upgradeURL } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let capActive = false;
|
||||
@@ -97,8 +95,8 @@
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button
|
||||
secondary
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'billing_budget_cap'
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import PaymentModal from '$routes/console/account/payments/paymentModal.svelte';
|
||||
import { hasStripePublicKey, isCloud } from '$lib/system';
|
||||
import { paymentMethods } from '$lib/stores/billing';
|
||||
import type { PaymentMethodData } from '$lib/sdk/billing';
|
||||
@@ -22,6 +21,7 @@
|
||||
import ReplaceCard from './replaceCard.svelte';
|
||||
import EditPaymentModal from '$routes/console/account/payments/editPaymentModal.svelte';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import PaymentModal from '$lib/components/billing/paymentModal.svelte';
|
||||
|
||||
let showDropdown = false;
|
||||
let showDropdownBackup = false;
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { plansInfo, tierToPlan } from '$lib/stores/billing';
|
||||
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Invoice } from '$lib/sdk/billing';
|
||||
@@ -162,7 +160,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
disabled={$organization?.markedForDeletion}
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}
|
||||
href={$upgradeURL}
|
||||
on:click={() =>
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
@@ -176,7 +174,7 @@
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion}
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}
|
||||
href={$upgradeURL}
|
||||
on:click={() =>
|
||||
trackEvent('click_organization_plan_update', {
|
||||
from: 'button',
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, invalidate } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Alert, LabelCard } from '$lib/components';
|
||||
import {
|
||||
EstimatedTotalBox,
|
||||
PlanComparisonBox,
|
||||
SelectPaymentMethod
|
||||
} from '$lib/components/billing';
|
||||
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
|
||||
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormList,
|
||||
InputSelect,
|
||||
InputTags,
|
||||
InputTextarea,
|
||||
Label
|
||||
} from '$lib/elements/forms';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import {
|
||||
WizardSecondaryContainer,
|
||||
WizardSecondaryContent,
|
||||
WizardSecondaryFooter,
|
||||
WizardSecondaryHeader
|
||||
} from '$lib/layout';
|
||||
import { type Coupon, type PaymentList } from '$lib/sdk/billing';
|
||||
import { plansInfo, tierFree, tierPro, tierToPlan, type Tier } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organization, organizationList, type Organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { VARS } from '$lib/system';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export let data;
|
||||
|
||||
$: anyOrgFree = $organizationList.teams?.find(
|
||||
(org) => (org as Organization)?.billingPlan === BillingPlan.STARTER
|
||||
);
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
|
||||
let previousPage: string = `${base}/console`;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
});
|
||||
|
||||
let formComponent: Form;
|
||||
let isSubmitting = writable(false);
|
||||
let methods: PaymentList;
|
||||
let billingPlan: Tier = $organization.billingPlan;
|
||||
let paymentMethodId: string;
|
||||
let collaborators: string[] =
|
||||
data?.members?.memberships
|
||||
?.map((m) => {
|
||||
if (m.userEmail !== $user.email) return m.userEmail;
|
||||
})
|
||||
?.filter(Boolean) ?? [];
|
||||
let couponData: Partial<Coupon> = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
let taxId: string;
|
||||
let billingBudget: number;
|
||||
let showCreditModal = false;
|
||||
|
||||
let feedbackDowngradeReason: string;
|
||||
let feedbackMessage: string;
|
||||
|
||||
onMount(async () => {
|
||||
if ($page.url.searchParams.has('coupon')) {
|
||||
const coupon = $page.url.searchParams.get('coupon');
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
couponData = response;
|
||||
} catch (e) {
|
||||
couponData = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
}
|
||||
}
|
||||
if ($page.url.searchParams.has('plan')) {
|
||||
const plan = $page.url.searchParams.get('plan');
|
||||
if (plan && plan in BillingPlan) {
|
||||
billingPlan = plan as BillingPlan;
|
||||
}
|
||||
}
|
||||
billingPlan = BillingPlan.PRO;
|
||||
});
|
||||
|
||||
async function loadPaymentMethods() {
|
||||
methods = await sdk.forConsole.billing.listPaymentMethods();
|
||||
|
||||
paymentMethodId =
|
||||
$organization?.paymentMethodId ??
|
||||
methods.paymentMethods.find((method) => !!method?.last4)?.$id ??
|
||||
null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (billingPlan === BillingPlan.STARTER) {
|
||||
await downgrade();
|
||||
} else {
|
||||
await upgrade();
|
||||
}
|
||||
}
|
||||
|
||||
async function downgrade() {
|
||||
try {
|
||||
await sdk.forConsole.billing.updatePlan(
|
||||
$organization.$id,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
null
|
||||
);
|
||||
|
||||
await fetch(`${VARS.GROWTH_ENDPOINT}/feedback/billing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: tierToPlan($organization.billingPlan).name,
|
||||
to: tierToPlan(billingPlan).name,
|
||||
email: $user.email,
|
||||
reason: feedbackDowngradeOptions.find(
|
||||
(option) => option.value === feedbackDowngradeReason
|
||||
)?.label,
|
||||
orgId: $organization.$id,
|
||||
userId: $user.$id,
|
||||
message: feedbackMessage ?? ''
|
||||
})
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
isHtml: true,
|
||||
message: `
|
||||
<b>${$organization.name}</b> has been changed to ${
|
||||
tierToPlan(billingPlan).name
|
||||
} plan.`
|
||||
});
|
||||
|
||||
trackEvent(Submit.OrganizationDowngrade, {
|
||||
plan: tierToPlan(billingPlan)?.name
|
||||
});
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(e, isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade);
|
||||
}
|
||||
}
|
||||
|
||||
async function upgrade() {
|
||||
try {
|
||||
const org = await sdk.forConsole.billing.updatePlan(
|
||||
$organization.$id,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
null
|
||||
);
|
||||
|
||||
//Add coupon
|
||||
if (couponData?.code) {
|
||||
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
|
||||
trackEvent(Submit.CreditRedeem);
|
||||
}
|
||||
|
||||
//Add budget
|
||||
if (billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
|
||||
}
|
||||
|
||||
//Add collaborators
|
||||
if (collaborators?.length) {
|
||||
const newCollaborators = collaborators.filter(
|
||||
(collaborator) =>
|
||||
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
|
||||
);
|
||||
newCollaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${$page.url.origin}/console/organization-${org.$id}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//Add tax ID
|
||||
if (taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
await goto(`/console/organization-${org.$id}`);
|
||||
if (isUpgrade) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Your organization has been upgraded'
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
isHtml: true,
|
||||
message: `
|
||||
<b>${$organization.name}</b> will change to ${
|
||||
tierToPlan(billingPlan).name
|
||||
} plan at the end of the current billing cycle.`
|
||||
});
|
||||
}
|
||||
trackEvent(isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade, {
|
||||
plan: tierToPlan(billingPlan)?.name
|
||||
});
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
trackError(e, isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade);
|
||||
}
|
||||
}
|
||||
|
||||
$: isUpgrade = billingPlan > $organization.billingPlan;
|
||||
$: freePlan = $plansInfo.get(BillingPlan.STARTER);
|
||||
$: proPlan = $plansInfo.get(BillingPlan.PRO);
|
||||
$: if (billingPlan === BillingPlan.PRO) {
|
||||
loadPaymentMethods();
|
||||
}
|
||||
$: isButtonDisabled =
|
||||
$organization.billingPlan === billingPlan
|
||||
? true
|
||||
: isUpgrade
|
||||
? !paymentMethodId
|
||||
: !feedbackDowngradeReason;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Change plan - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<WizardSecondaryContainer>
|
||||
<WizardSecondaryHeader href={previousPage}>Change plan</WizardSecondaryHeader>
|
||||
<WizardSecondaryContent>
|
||||
<Form bind:this={formComponent} onSubmit={handleSubmit} bind:isSubmitting>
|
||||
<Label class="label u-margin-block-start-16">Select plan</Label>
|
||||
<p class="text">
|
||||
For more details on our plans, visit our
|
||||
<Button href="https://appwrite.io/pricing" external link>pricing page</Button>.
|
||||
</p>
|
||||
{#if anyOrgFree && billingPlan === BillingPlan.PRO}
|
||||
<Alert type="warning" class="u-margin-block-16">
|
||||
You are limited to one Starter organization per account. Consider upgrading or
|
||||
deleting <Button link href={`${base}/console/organization-${anyOrgFree.$id}`}
|
||||
>{anyOrgFree.name}</Button
|
||||
>.
|
||||
</Alert>
|
||||
{/if}
|
||||
<ul
|
||||
class="u-flex u-gap-16 u-margin-block-start-8"
|
||||
style="--p-grid-item-size:16em; --p-grid-item-size-small-screens:16rem; --grid-gap: 1rem;">
|
||||
<li class="u-flex-basis-50-percent">
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
value="tier-0"
|
||||
disabled={!!anyOrgFree}
|
||||
tooltipShow={!!anyOrgFree}
|
||||
tooltipText="You are limited to 1 Free organization per account.">
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierFree.name}
|
||||
{#if $organization.billingPlan === BillingPlan.STARTER}
|
||||
<span class="inline-tag">Current plan</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">{tierFree.description}</p>
|
||||
<p>
|
||||
{formatCurrency(freePlan?.price ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
|
||||
<li class="u-flex-basis-50-percent">
|
||||
<LabelCard name="plan" bind:group={billingPlan} value="tier-1">
|
||||
<svelte:fragment slot="custom">
|
||||
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierPro.name}
|
||||
{#if $organization.billingPlan === BillingPlan.PRO}
|
||||
<span class="inline-tag">Current plan</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierPro.description}
|
||||
</p>
|
||||
<p>
|
||||
{formatCurrency(proPlan?.price ?? 0)} per member/month + usage
|
||||
</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
</ul>
|
||||
{#if billingPlan === BillingPlan.PRO && $organization.billingPlan !== BillingPlan.PRO}
|
||||
<FormList class="u-margin-block-start-16">
|
||||
<InputTags
|
||||
bind:tags={collaborators}
|
||||
label="Invite members by email"
|
||||
tooltip="Invited members will have access to all services and payment data within your organization"
|
||||
placeholder="Enter email address(es)"
|
||||
validityRegex={emailRegex}
|
||||
validityMessage="Invalid email address"
|
||||
id="members" />
|
||||
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId />
|
||||
</FormList>
|
||||
{#if !couponData?.code}
|
||||
<Button
|
||||
text
|
||||
noMargin
|
||||
class="u-margin-block-start-16"
|
||||
on:click={() => (showCreditModal = true)}>
|
||||
<span class="icon-plus"></span> <span class="text">Add credits</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isUpgrade && billingPlan === BillingPlan.STARTER && $organization.billingPlan !== BillingPlan.STARTER}
|
||||
<FormList class="u-margin-block-start-16">
|
||||
<InputSelect
|
||||
id="reason"
|
||||
label="What made you decide to change your plan?"
|
||||
placeholder="Select one"
|
||||
required
|
||||
options={feedbackDowngradeOptions}
|
||||
bind:value={feedbackDowngradeReason} />
|
||||
<InputTextarea
|
||||
id="comment"
|
||||
label="Your feedback here"
|
||||
placeholder="Enter feedback"
|
||||
bind:value={feedbackMessage} />
|
||||
</FormList>
|
||||
{/if}
|
||||
</Form>
|
||||
<svelte:fragment slot="aside">
|
||||
{#if billingPlan !== BillingPlan.STARTER && $organization.billingPlan !== BillingPlan.PRO}
|
||||
<EstimatedTotalBox
|
||||
{billingPlan}
|
||||
{collaborators}
|
||||
bind:couponData
|
||||
bind:billingBudget />
|
||||
{:else}
|
||||
<PlanComparisonBox />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</WizardSecondaryContent>
|
||||
|
||||
<WizardSecondaryFooter>
|
||||
<Button fullWidthMobile href={`${base}/console`} secondary>Cancel</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
disabled={$isSubmitting || isButtonDisabled}>
|
||||
Change plan
|
||||
</Button>
|
||||
</WizardSecondaryFooter>
|
||||
</WizardSecondaryContainer>
|
||||
|
||||
<ValidateCreditModal bind:show={showCreditModal} bind:couponData />
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
export const load = async ({ depends, parent }) => {
|
||||
const { members } = await parent();
|
||||
depends(Dependencies.ORGANIZATION);
|
||||
|
||||
return {
|
||||
members
|
||||
};
|
||||
};
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from '$lib/components';
|
||||
import { sizeToBytes } from '$lib/helpers/sizeConvertion';
|
||||
import { plansInfo, tierToPlan } from '$lib/stores/billing';
|
||||
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { onMount } from 'svelte';
|
||||
import PlanExcess from '../wizard/cloudOrganizationChangeTier/planExcess.svelte';
|
||||
import type { OrganizationUsage } from '$lib/sdk/billing';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '../changeOrganizationTierCloud.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { last } from '$lib/helpers/array';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import PlanExcess from '$lib/components/billing/planExcess.svelte';
|
||||
|
||||
export let show = false;
|
||||
const plan = $plansInfo?.get($organization.billingPlan);
|
||||
@@ -78,9 +76,9 @@
|
||||
View usage
|
||||
</Button>
|
||||
<Button
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'limit_reached_modal'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
@@ -31,9 +32,7 @@
|
||||
organization,
|
||||
organizationList
|
||||
} from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
|
||||
import CreateOrganizationCloud from '../createOrganizationCloud.svelte';
|
||||
|
||||
let areMembersLimited: boolean;
|
||||
$: organization.subscribe(() => {
|
||||
@@ -48,7 +47,7 @@
|
||||
function createOrg() {
|
||||
showDropdown = false;
|
||||
if (isCloud) {
|
||||
wizard.start(CreateOrganizationCloud);
|
||||
goto(`${base}/console/create-organization`);
|
||||
} else newOrgModal.set(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Container } from '$lib/layout';
|
||||
import { Card, CardGrid, Heading, ProgressBarBig } from '$lib/components';
|
||||
import { getServiceLimit, showUsageRatesModal, tierToPlan } from '$lib/stores/billing';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import {
|
||||
getServiceLimit,
|
||||
showUsageRatesModal,
|
||||
tierToPlan,
|
||||
upgradeURL
|
||||
} from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { bytesToSize, humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { BarChart } from '$lib/charts';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import ProjectBreakdown from './ProjectBreakdown.svelte';
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
import { accumulateFromEndingTotal, total } from '$lib/layout/usage.svelte';
|
||||
@@ -45,8 +48,8 @@
|
||||
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
wizard.start(ChangeOrganizationTierCloud);
|
||||
trackEvent('click_organization_upgrade', {
|
||||
from: 'button',
|
||||
source: 'organization_usage'
|
||||
@@ -77,11 +80,7 @@
|
||||
<p class="text">
|
||||
If you exceed the limits of the {plan} plan, services for your organization's projects
|
||||
may be disrupted.
|
||||
<button
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}
|
||||
class="link"
|
||||
type="button">Upgrade for greater capacity</button
|
||||
>.
|
||||
<a href={$upgradeURL} class="link">Upgrade for greater capacity</a>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { Alert, CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
|
||||
@@ -51,8 +50,6 @@
|
||||
import { baseEmailTemplate, emailTemplate } from './store';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import type {
|
||||
SmsTemplateLocale,
|
||||
@@ -110,10 +107,8 @@
|
||||
type="info"
|
||||
buttons={[
|
||||
{
|
||||
name: 'SMTP settings',
|
||||
method: () => {
|
||||
goto(`${base}/console/project-${$project.$id}/settings/smtp`);
|
||||
}
|
||||
slot: 'SMTP settings',
|
||||
href: `${base}/console/project-${$project.$id}/settings/smtp`
|
||||
}
|
||||
]}>
|
||||
<svelte:fragment slot="title">
|
||||
@@ -140,8 +135,8 @@
|
||||
<Alert
|
||||
buttons={[
|
||||
{
|
||||
name: 'Upgrade plan',
|
||||
method: () => wizard.start(ChangeOrganizationTierCloud)
|
||||
slot: 'Upgrade plan',
|
||||
href: `${base}/console/organization-${$organization.$id}/billing`
|
||||
}
|
||||
]}>
|
||||
All emails sent using the Starter plan will include attribution to Appwrite in
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
type="error"
|
||||
buttons={[
|
||||
{
|
||||
name: 'View logs',
|
||||
method() {
|
||||
slot: 'View logs',
|
||||
onClick() {
|
||||
tab = 'logs';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import { onMount } from 'svelte';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { SMTPSecure } from '@appwrite.io/console';
|
||||
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
|
||||
import { upgradeURL } from '$lib/stores/billing';
|
||||
|
||||
let enabled = false;
|
||||
let senderName: string;
|
||||
@@ -137,11 +136,7 @@
|
||||
Custom SMTP is a Pro plan feature. Upgrade to enable custom SMTP sever.
|
||||
<svelte:fragment slot="action">
|
||||
<div class="alert-buttons u-flex">
|
||||
<Button
|
||||
text
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
<Button text href={$upgradeURL}>Upgrade plan</Button>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Alert>
|
||||
|
||||
@@ -10,15 +10,13 @@
|
||||
TableRow,
|
||||
Table
|
||||
} from '$lib/elements/table';
|
||||
import { showUsageRatesModal, tierToPlan } from '$lib/stores/billing';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { showUsageRatesModal, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { BarChart } from '$lib/charts';
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
import { total } from '$lib/layout/usage.svelte';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { BillingPlan } from '$lib/constants.js';
|
||||
|
||||
export let data;
|
||||
@@ -53,7 +51,7 @@
|
||||
<Heading tag="h2" size="5">Usage</Heading>
|
||||
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<Button on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
<Button href={$upgradeURL}>
|
||||
<span class="text">Upgrade</span>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -76,11 +74,7 @@
|
||||
{:else if $organization.billingPlan === BillingPlan.STARTER}
|
||||
<p class="text">
|
||||
If you exceed the limits of the {plan} plan, services for your projects may be disrupted.
|
||||
<button
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}
|
||||
class="link"
|
||||
type="button">Upgrade for greater capacity</button
|
||||
>.
|
||||
<a href={$upgradeURL} class="link">Upgrade for greater capacity</a>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
+2
-6
@@ -5,11 +5,9 @@
|
||||
import { Button, FormList, InputFile } from '$lib/elements/forms';
|
||||
import { humanFileSize, sizeToBytes } from '$lib/helpers/sizeConvertion';
|
||||
import WizardStep from '$lib/layout/wizardStep.svelte';
|
||||
import { getServiceLimit, tierToPlan } from '$lib/stores/billing';
|
||||
import { getServiceLimit, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { bucket } from '../store';
|
||||
import { createFile } from './store';
|
||||
|
||||
@@ -36,9 +34,7 @@
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<div class="alert-buttons u-flex">
|
||||
<Button text on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
<Button text href={$upgradeURL}>Upgrade plan</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
+2
-8
@@ -5,11 +5,9 @@
|
||||
import { Button, Form, FormItem, InputNumber, InputSelect } from '$lib/elements/forms';
|
||||
import { humanFileSize, sizeToBytes } from '$lib/helpers/sizeConvertion';
|
||||
import { createByteUnitPair } from '$lib/helpers/unit';
|
||||
import { getServiceLimit, readOnly, tierToPlan } from '$lib/stores/billing';
|
||||
import { getServiceLimit, readOnly, tierToPlan, upgradeURL } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
|
||||
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
|
||||
import { bucket } from '../store';
|
||||
import { updateBucket } from './+page.svelte';
|
||||
|
||||
@@ -50,11 +48,7 @@
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === BillingPlan.STARTER}
|
||||
<div class="alert-buttons u-flex">
|
||||
<Button
|
||||
text
|
||||
on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
<Button text href={$upgradeURL}>Upgrade plan</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { FormItem, FormList, InputSelect, InputText } from '$lib/elements/forms';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { onMount } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { createOrganization } from './store';
|
||||
import type { AddressesList } from '$lib/sdk/billing';
|
||||
import { RadioBoxes } from '$lib/components';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
let options = [
|
||||
{
|
||||
value: 'US',
|
||||
label: 'United States'
|
||||
}
|
||||
];
|
||||
let addressList: AddressesList;
|
||||
let country: string;
|
||||
let streetAddress: string;
|
||||
let city: string;
|
||||
let state: string;
|
||||
let postalCode: string;
|
||||
let addressLine2: string;
|
||||
|
||||
async function handleAddress() {
|
||||
if (!$createOrganization.billingAddressId) {
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.createAddress(
|
||||
country,
|
||||
streetAddress,
|
||||
city,
|
||||
state,
|
||||
postalCode ? postalCode : undefined,
|
||||
addressLine2 ? addressLine2 : undefined
|
||||
);
|
||||
$createOrganization.billingAddressId = response.$id;
|
||||
trackEvent(Submit.BillingAddressCreate);
|
||||
} catch (error) {
|
||||
trackError(error, Submit.BillingAddressCreate);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
addressList = await sdk.forConsole.billing.listAddresses();
|
||||
$createOrganization.billingAddressId = addressList.billingAddresses?.[0]?.$id ?? null;
|
||||
const countryList = await sdk.forProject.locale.listCountries();
|
||||
const locale = await sdk.forProject.locale.get();
|
||||
if (locale?.countryCode) {
|
||||
country = locale.countryCode;
|
||||
}
|
||||
options = countryList.countries.map((country) => {
|
||||
return {
|
||||
value: country.code,
|
||||
label: country.name
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleAddress}>
|
||||
<svelte:fragment slot="title">Billing address</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">Add a billing address for your organization.</svelte:fragment>
|
||||
|
||||
<FormList class="u-margin-block-start-8">
|
||||
{#if addressList}
|
||||
<RadioBoxes
|
||||
total={addressList?.total}
|
||||
name="address"
|
||||
bind:group={$createOrganization.billingAddressId}
|
||||
elements={addressList.billingAddresses}>
|
||||
<svelte:fragment slot="element" let:element>
|
||||
<div
|
||||
class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2"
|
||||
style="padding-inline:0.25rem">
|
||||
<p class="text">{element.streetAddress}</p>
|
||||
{#if element?.addressLine2}
|
||||
<p class="text">{element.addressLine2}</p>
|
||||
{/if}
|
||||
<p class="text">{element.city}</p>
|
||||
<p class="text">{element.state}</p>
|
||||
<p class="text">{element.postalCode}</p>
|
||||
<p class="text">{element.country}</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="new">
|
||||
<span style="padding-inline:0.25rem">Add new billing address</span>
|
||||
</svelte:fragment>
|
||||
|
||||
<FormList gap={16} class="u-margin-block-start-24">
|
||||
<InputText
|
||||
bind:value={$createOrganization.taxId}
|
||||
id="text"
|
||||
label="Tax ID"
|
||||
placeholder="Enter tax ID"
|
||||
optionalText="(optional)" />
|
||||
<InputSelect
|
||||
bind:value={country}
|
||||
{options}
|
||||
label="Country or region"
|
||||
placeholder="Select country or region"
|
||||
id="country"
|
||||
required />
|
||||
<InputText
|
||||
bind:value={streetAddress}
|
||||
id="address"
|
||||
label="Street address"
|
||||
placeholder="Enter street address"
|
||||
required />
|
||||
<InputText
|
||||
bind:value={addressLine2}
|
||||
id="address2"
|
||||
label="Address line 2"
|
||||
placeholder="Unit number, floor, etc." />
|
||||
<InputText
|
||||
bind:value={city}
|
||||
id="city"
|
||||
label="City or suburb"
|
||||
placeholder="Enter your city"
|
||||
required />
|
||||
<FormItem isMultiple>
|
||||
<InputText
|
||||
isMultiple
|
||||
fullWidth
|
||||
bind:value={state}
|
||||
id="state"
|
||||
label="State"
|
||||
placeholder="Enter your state"
|
||||
required />
|
||||
<InputText
|
||||
isMultiple
|
||||
fullWidth
|
||||
bind:value={postalCode}
|
||||
id="zip"
|
||||
label="Postal code"
|
||||
placeholder="Enter postal code" />
|
||||
</FormItem>
|
||||
</FormList>
|
||||
</RadioBoxes>
|
||||
{/if}
|
||||
</FormList>
|
||||
</WizardStep>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Box, CreditCardBrandImage } from '$lib/components';
|
||||
import { CouponInput } from '$lib/components/billing';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Pill } from '$lib/elements';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { createOrganization, createOrganizationFinalAction } from './store';
|
||||
|
||||
const plan = $plansInfo?.get($createOrganization.billingPlan);
|
||||
const collaboratorPrice = plan?.addons.member?.price ?? 0;
|
||||
const collaboratorsNumber = $createOrganization?.collaborators?.length ?? 0;
|
||||
const totalExpences = plan.price + collaboratorPrice * collaboratorsNumber;
|
||||
const today = new Date();
|
||||
const billingPayDate = new Date(today.getTime() + 44 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let coupon: string = null;
|
||||
let couponData: Partial<Coupon> = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
|
||||
async function fetchCard() {
|
||||
try {
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(
|
||||
$createOrganization.paymentMethodId
|
||||
);
|
||||
return card;
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBefore() {
|
||||
if (!$createOrganization.billingPlan) {
|
||||
throw new Error('Please select a plan.');
|
||||
}
|
||||
if ($createOrganization.billingPlan === BillingPlan.STARTER) {
|
||||
$createOrganization.collaborators = [];
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($createOrganization.billingPlan === BillingPlan.STARTER) {
|
||||
$createOrganizationFinalAction = 'Create organization';
|
||||
}
|
||||
|
||||
$: if (plan?.trialDays) {
|
||||
$createOrganizationFinalAction = 'Start trial';
|
||||
}
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleBefore}>
|
||||
<svelte:fragment slot="title">Details</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Confirm the details of your new organization{!plan.trialDays
|
||||
? '.'
|
||||
: ' and start your free trial.'}
|
||||
</svelte:fragment>
|
||||
|
||||
<p class="body-text-1 u-bold">Organization name</p>
|
||||
<div class="u-flex u-gap-8 u-cross-center u-margin-block-start-8">
|
||||
<p class="text">{$createOrganization.name}</p>
|
||||
{#if $createOrganization?.id}
|
||||
<Pill>{$createOrganization.id}</Pill>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $createOrganization.billingPlan !== BillingPlan.STARTER}
|
||||
<div class="u-margin-block-start-32">
|
||||
<p class="body-text-1 u-bold">Additional members</p>
|
||||
<p class="text u-margin-block-start-8">{collaboratorsNumber} members</p>
|
||||
</div>
|
||||
<div class="u-margin-block-start-32">
|
||||
<p class="body-text-1 u-bold">Payment</p>
|
||||
{#await fetchCard()}
|
||||
<div class="u-flex u-margin-block-start-8">
|
||||
<div class="loader is-small" />
|
||||
</div>
|
||||
{:then card}
|
||||
<span class="u-flex u-cross-center u-gap-8 u-margin-block-start-8">
|
||||
<p class="text">
|
||||
<span class="u-capitalize">{card?.brand}</span> ending in {card?.last4}
|
||||
</p>
|
||||
<CreditCardBrandImage brand={card?.brand} />
|
||||
</span>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
<Box class="u-margin-block-start-32 u-flex u-flex-vertical u-gap-16" radius="small">
|
||||
{#if $createOrganization.billingPlan !== BillingPlan.STARTER}
|
||||
<CouponInput
|
||||
bind:coupon
|
||||
bind:couponData
|
||||
on:validation={(e) => ($createOrganization.couponCode = e.detail.code)} />
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">{plan.name} plan</p>
|
||||
<p class="text">{formatCurrency(plan.price)}</p>
|
||||
</span>
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Additional members ({collaboratorsNumber})</p>
|
||||
<p class="text">{formatCurrency(collaboratorPrice * collaboratorsNumber)}</p>
|
||||
</span>
|
||||
{#if couponData?.status === 'active'}
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Credits applied ({couponData.credits})</p>
|
||||
<p class="text">-{formatCurrency(couponData.credits)}</p>
|
||||
</span>
|
||||
{/if}
|
||||
<div class="u-sep-block-start" />
|
||||
{/if}
|
||||
{@const estimatedTotal =
|
||||
couponData?.status === 'active'
|
||||
? totalExpences - couponData.credits >= 0
|
||||
? totalExpences - couponData.credits
|
||||
: 0
|
||||
: totalExpences}
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Estimated total</p>
|
||||
<p class="text">
|
||||
{formatCurrency(estimatedTotal)}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
{#if $createOrganization.billingPlan !== BillingPlan.STARTER}
|
||||
<p class="text u-margin-block-start-16">
|
||||
This amount, and any additional usage fees, will be charged on a recurring 30-day
|
||||
billing cycle{!plan.trialDays
|
||||
? ''
|
||||
: ` after your trial period ends on ${toLocaleDate(billingPayDate.toString())}`}.
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
</WizardStep>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Button, Form, FormList, InputEmail } from '$lib/elements/forms';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellHead,
|
||||
TableCellText,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/elements/table';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { createOrganization } from './store';
|
||||
|
||||
let email: string;
|
||||
|
||||
function addCollaborator() {
|
||||
if (!email) return;
|
||||
if ($createOrganization.collaborators.includes(email)) return;
|
||||
$createOrganization.collaborators.push(email);
|
||||
$createOrganization = $createOrganization;
|
||||
email = '';
|
||||
}
|
||||
|
||||
function removeCollaborator(email: string) {
|
||||
$createOrganization.collaborators = $createOrganization.collaborators.filter(
|
||||
(collaborator) => collaborator !== email
|
||||
);
|
||||
}
|
||||
|
||||
const plan = $plansInfo?.get($createOrganization.billingPlan);
|
||||
</script>
|
||||
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">Invites</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Invite team members to collaborate with you in the Appwrite console. Members will have
|
||||
access to all services and payment data within your organization.
|
||||
</svelte:fragment>
|
||||
|
||||
<Alert type="info">
|
||||
{#if $createOrganization.billingPlan === BillingPlan.SCALE}
|
||||
You can add unlimited organization members on the {plan.name} plan at no cost. Each member
|
||||
added will receive an email invite to your organization on completion.
|
||||
{:else if $createOrganization.billingPlan === BillingPlan.PRO}
|
||||
You can add unlimited organization members on the {plan.name} plan for
|
||||
<b>{formatCurrency(plan.addons.member.price)} each per month</b>. Each member added will
|
||||
receive an email invite to your organization on completion.
|
||||
{/if}
|
||||
</Alert>
|
||||
|
||||
<div class="u-margin-block-start-24">
|
||||
<Form onSubmit={addCollaborator}>
|
||||
<FormList>
|
||||
<InputEmail
|
||||
label="Email address"
|
||||
id="email"
|
||||
placeholder="Email address"
|
||||
bind:value={email}>
|
||||
<Button secondary submit>Add</Button>
|
||||
</InputEmail>
|
||||
</FormList>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{#if $createOrganization?.collaborators?.length}
|
||||
<div class="u-margin-block-start-24">
|
||||
<Table noStyles noMargin>
|
||||
<TableHeader>
|
||||
<TableCellHead>Collaborator</TableCellHead>
|
||||
{#if $createOrganization.billingPlan === BillingPlan.PRO}
|
||||
<TableCellHead width={80}>Cost</TableCellHead>
|
||||
{/if}
|
||||
<TableCellHead width={40} />
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each $createOrganization.collaborators as collaborator}
|
||||
<TableRow>
|
||||
<TableCellText title="collaborator">{collaborator}</TableCellText>
|
||||
{#if $createOrganization.billingPlan === BillingPlan.PRO}
|
||||
<TableCellText title="cost">{formatCurrency(15)}</TableCellText>
|
||||
{/if}
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
style="--button-size:1.5rem;"
|
||||
aria-label="remove collaborator"
|
||||
on:click={() => removeCollaborator(collaborator)}>
|
||||
<span class="icon-x" aria-hidden="true" />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/if}
|
||||
</WizardStep>
|
||||
@@ -1,142 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { CustomId, LabelCard } from '$lib/components';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Pill } from '$lib/elements';
|
||||
import { InputText, FormList } from '$lib/elements/forms';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { plansInfo, tierFree, tierPro, tierScale } from '$lib/stores/billing';
|
||||
import { organizationList, type Organization } from '$lib/stores/organization';
|
||||
import { updateStepStatus } from '$lib/stores/wizard';
|
||||
import { createOrganization, createOrgSteps } from './store';
|
||||
|
||||
let showCustomId = false;
|
||||
|
||||
$: anyOrgFree = $organizationList.teams?.find(
|
||||
(org) => (org as Organization)?.billingPlan === BillingPlan.STARTER
|
||||
);
|
||||
|
||||
$: if ($createOrganization.billingPlan === BillingPlan.STARTER && $createOrgSteps) {
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 2, true);
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 3, true);
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 4, true);
|
||||
}
|
||||
|
||||
$: if (
|
||||
$createOrganization.billingPlan === BillingPlan.SCALE ||
|
||||
$createOrganization.billingPlan === BillingPlan.PRO
|
||||
) {
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 2, false);
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 3, false);
|
||||
$createOrgSteps = updateStepStatus($createOrgSteps, 4, false);
|
||||
}
|
||||
|
||||
$: freePlan = $plansInfo.get(BillingPlan.STARTER);
|
||||
$: proPlan = $plansInfo.get(BillingPlan.PRO);
|
||||
$: scalePlan = $plansInfo.get(BillingPlan.SCALE);
|
||||
</script>
|
||||
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">Organization details</svelte:fragment>
|
||||
<FormList>
|
||||
<InputText
|
||||
label="Name"
|
||||
id="name"
|
||||
autofocus
|
||||
placeholder="Organization name"
|
||||
bind:value={$createOrganization.name}
|
||||
required />
|
||||
|
||||
{#if !showCustomId}
|
||||
<div>
|
||||
<Pill button on:click={() => (showCustomId = !showCustomId)}>
|
||||
<span class="icon-pencil" aria-hidden="true" />
|
||||
<span class="text">Organization ID </span>
|
||||
</Pill>
|
||||
</div>
|
||||
{:else}
|
||||
<CustomId
|
||||
fullWidth
|
||||
bind:show={showCustomId}
|
||||
name="Organization"
|
||||
bind:id={$createOrganization.id} />
|
||||
{/if}
|
||||
</FormList>
|
||||
|
||||
<p class="body-text-1 u-bold common-section">Plan</p>
|
||||
<p class="text u-margin-block-start-4">
|
||||
For more details on our plans, visit our <a
|
||||
class="link"
|
||||
href="http://appwrite.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">pricing page</a
|
||||
>.
|
||||
</p>
|
||||
<ul
|
||||
class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8"
|
||||
style="--p-grid-item-size:16em; --p-grid-item-size-small-screens:16rem; --grid-gap: 1rem;">
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={$createOrganization.billingPlan}
|
||||
value="tier-0"
|
||||
disabled={!!anyOrgFree}
|
||||
tooltipText="You are limited to 1 Free organization per account."
|
||||
tooltipShow={!!anyOrgFree}>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierFree.name} - {formatCurrency(freePlan?.price ?? 0)}/month
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">{tierFree.description}</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<LabelCard name="plan" bind:group={$createOrganization.billingPlan} value="tier-1">
|
||||
<svelte:fragment slot="custom">
|
||||
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierPro.name} - {formatCurrency(proPlan?.price ?? 0)}/month per
|
||||
organization member + exta usage
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierPro.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if proPlan?.trialDays}
|
||||
<Pill>14 DAY FREE TRIAL</Pill>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={$createOrganization.billingPlan}
|
||||
value="tier-2"
|
||||
disabled>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierScale.name} - {formatCurrency(scalePlan?.price ?? 0)}/month + extra
|
||||
usage
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierScale.description}
|
||||
</p>
|
||||
</div>
|
||||
<div class:u-opacity-50={disabled}>
|
||||
<Pill disabled>COMING SOON</Pill>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
</ul>
|
||||
</WizardStep>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { FormList, InputNumber } from '$lib/elements/forms';
|
||||
import InputChoice from '$lib/elements/forms/inputChoice.svelte';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { onMount } from 'svelte';
|
||||
import { createOrganization } from './store';
|
||||
import type { PaymentList } from '$lib/sdk/billing';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { initializeStripe, isStripeInitialized, submitStripeCard } from '$lib/stores/stripe';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { plansInfo, showUsageRatesModal } from '$lib/stores/billing';
|
||||
import { PaymentBoxes } from '$lib/components/billing';
|
||||
|
||||
const today = new Date();
|
||||
const billingPayDate = new Date(today.getTime() + 44 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let methods: PaymentList;
|
||||
let name: string;
|
||||
let budgetEnabled = false;
|
||||
let initialPaymentMethodId: string;
|
||||
|
||||
onMount(async () => {
|
||||
methods = await sdk.forConsole.billing.listPaymentMethods();
|
||||
initialPaymentMethodId =
|
||||
methods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
|
||||
$createOrganization.paymentMethodId = initialPaymentMethodId;
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if ($createOrganization.billingBudget < 0) {
|
||||
throw new Error('Budget cannot be negative');
|
||||
}
|
||||
if ($createOrganization.paymentMethodId) {
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(
|
||||
$createOrganization.paymentMethodId
|
||||
);
|
||||
if (!card?.last4) {
|
||||
throw new Error(
|
||||
'The payment method you selected is not valid. Please select a different one.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const method = await submitStripeCard(name);
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(method.$id);
|
||||
if (card?.last4) {
|
||||
$createOrganization.paymentMethodId = card.$id;
|
||||
} else {
|
||||
throw new Error(
|
||||
'The payment method you selected is not valid. Please select a different one.'
|
||||
);
|
||||
}
|
||||
invalidate(Dependencies.PAYMENT_METHODS);
|
||||
} catch (e) {
|
||||
$createOrganization.paymentMethodId = initialPaymentMethodId;
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($createOrganization.paymentMethodId === null && !$isStripeInitialized) {
|
||||
initializeStripe();
|
||||
}
|
||||
|
||||
$: if ($createOrganization.paymentMethodId) {
|
||||
isStripeInitialized.set(false);
|
||||
}
|
||||
|
||||
$: filteredMethods = methods?.paymentMethods.filter((method) => !!method?.last4);
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleSubmit}>
|
||||
<svelte:fragment slot="title">Payment details</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Add a payment method to your organization. {#if $plansInfo.get($createOrganization.billingPlan)?.trialDays}You
|
||||
will not be charged until your trial ends on <b
|
||||
>{toLocaleDate(billingPayDate.toString())}</b
|
||||
>.
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<FormList class="u-margin-block-start-8">
|
||||
<PaymentBoxes
|
||||
methods={filteredMethods}
|
||||
bind:name
|
||||
bind:group={$createOrganization.paymentMethodId} />
|
||||
|
||||
<InputChoice
|
||||
type="switchbox"
|
||||
id="budget"
|
||||
label="Enable budget cap"
|
||||
tooltip="If enabled, you will be notified by email when your organization spend reaches 75% of the cap you set. Update your budget cap alerts in organization Settings."
|
||||
fullWidth
|
||||
bind:value={budgetEnabled}>
|
||||
<p class="text">
|
||||
Restrict your resource usage by setting a budget cap. <button
|
||||
class="link"
|
||||
type="button"
|
||||
on:click={() => ($showUsageRatesModal = true)}>
|
||||
Learn more about usage rates</button
|
||||
>.
|
||||
</p>
|
||||
{#if budgetEnabled}
|
||||
<div class="u-margin-block-start-16">
|
||||
<InputNumber
|
||||
id="budget"
|
||||
label="Budget cap (USD)"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
bind:value={$createOrganization.billingBudget} />
|
||||
</div>
|
||||
{/if}
|
||||
</InputChoice>
|
||||
</FormList>
|
||||
</WizardStep>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import type { WizardStepsType } from '$lib/layout/wizard.svelte';
|
||||
import { type Tier } from '$lib/stores/billing';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const createOrgSteps = writable<WizardStepsType>(new Map());
|
||||
export const createOrganizationFinalAction = writable<string>('Create');
|
||||
|
||||
export const createOrganization = writable<{
|
||||
id?: string;
|
||||
name: string;
|
||||
billingPlan: Tier;
|
||||
paymentMethodId: string;
|
||||
billingAddressId: string;
|
||||
collaborators?: string[];
|
||||
billingBudget?: number;
|
||||
taxId?: string;
|
||||
couponCode?: string;
|
||||
}>({
|
||||
id: null,
|
||||
name: null,
|
||||
billingPlan: BillingPlan.PRO,
|
||||
paymentMethodId: null,
|
||||
collaborators: [],
|
||||
billingAddressId: null,
|
||||
taxId: null,
|
||||
couponCode: null
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { FormItem, FormList, InputSelect, InputText } from '$lib/elements/forms';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { onMount } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { changeOrganizationTier } from './store';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import type { AddressesList } from '$lib/sdk/billing';
|
||||
import { RadioBoxes } from '$lib/components';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
let options = [
|
||||
{
|
||||
value: 'US',
|
||||
label: 'United States'
|
||||
}
|
||||
];
|
||||
let addressList: AddressesList;
|
||||
let country: string;
|
||||
let streetAddress: string;
|
||||
let city: string;
|
||||
let state: string;
|
||||
let postalCode: string;
|
||||
let addressLine2: string;
|
||||
|
||||
async function handleAddress() {
|
||||
if (!$changeOrganizationTier.billingAddressId) {
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.createAddress(
|
||||
country,
|
||||
streetAddress,
|
||||
city,
|
||||
state,
|
||||
postalCode ? postalCode : undefined,
|
||||
addressLine2 ? addressLine2 : undefined
|
||||
);
|
||||
$changeOrganizationTier.billingAddressId = response.$id;
|
||||
trackEvent(Submit.BillingAddressCreate);
|
||||
} catch (error) {
|
||||
trackError(error, Submit.BillingAddressCreate);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
addressList = await sdk.forConsole.billing.listAddresses();
|
||||
$changeOrganizationTier.billingAddressId = $organization.billingAddressId
|
||||
? $organization.billingAddressId
|
||||
: addressList.billingAddresses?.[0]?.$id ?? null;
|
||||
const locale = await sdk.forProject.locale.get();
|
||||
if (locale?.countryCode && !$changeOrganizationTier.billingAddressId) {
|
||||
country = locale.countryCode;
|
||||
}
|
||||
const countryList = await sdk.forProject.locale.listCountries();
|
||||
options = countryList.countries.map((country) => {
|
||||
return {
|
||||
value: country.code,
|
||||
label: country.name
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleAddress}>
|
||||
<svelte:fragment slot="title">Billing address</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">Add a billing address for your organization.</svelte:fragment>
|
||||
|
||||
<FormList class="u-margin-block-start-8">
|
||||
{#if addressList}
|
||||
<RadioBoxes
|
||||
total={addressList?.total}
|
||||
name="address"
|
||||
bind:group={$changeOrganizationTier.billingAddressId}
|
||||
elements={addressList.billingAddresses}>
|
||||
<svelte:fragment slot="element" let:element>
|
||||
<div
|
||||
class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2"
|
||||
style="padding-inline:0.25rem">
|
||||
<p class="text">{element.streetAddress}</p>
|
||||
{#if element?.addressLine2}
|
||||
<p class="text">{element.addressLine2}</p>
|
||||
{/if}
|
||||
<p class="text">{element.city}</p>
|
||||
<p class="text">{element.state}</p>
|
||||
<p class="text">{element.postalCode}</p>
|
||||
<p class="text">{element.country}</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="new">
|
||||
<span style="padding-inline:0.25rem">Add new billing address</span>
|
||||
</svelte:fragment>
|
||||
|
||||
<FormList gap={16} class="u-margin-block-start-24">
|
||||
<InputText
|
||||
bind:value={$changeOrganizationTier.taxId}
|
||||
id="text"
|
||||
label="Tax ID"
|
||||
placeholder="Enter tax ID"
|
||||
optionalText="(optional)" />
|
||||
<InputSelect
|
||||
bind:value={country}
|
||||
{options}
|
||||
label="Country or region"
|
||||
placeholder="Select country or region"
|
||||
id="country"
|
||||
required />
|
||||
<InputText
|
||||
bind:value={streetAddress}
|
||||
id="address"
|
||||
label="Street address"
|
||||
placeholder="Enter street address"
|
||||
required />
|
||||
<InputText
|
||||
bind:value={addressLine2}
|
||||
id="address2"
|
||||
label="Address line 2"
|
||||
placeholder="Unit number, floor, etc." />
|
||||
<InputText
|
||||
bind:value={city}
|
||||
id="city"
|
||||
label="City or suburb"
|
||||
placeholder="Enter your city"
|
||||
required />
|
||||
<FormItem isMultiple>
|
||||
<InputText
|
||||
isMultiple
|
||||
fullWidth
|
||||
bind:value={state}
|
||||
id="state"
|
||||
label="State"
|
||||
placeholder="Enter your state"
|
||||
required />
|
||||
<InputText
|
||||
isMultiple
|
||||
fullWidth
|
||||
bind:value={postalCode}
|
||||
id="zip"
|
||||
label="Postal code"
|
||||
placeholder="Enter postal code" />
|
||||
</FormItem>
|
||||
</FormList>
|
||||
</RadioBoxes>
|
||||
{/if}
|
||||
</FormList>
|
||||
</WizardStep>
|
||||
@@ -1,195 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { LabelCard } from '$lib/components';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { plansInfo, tierFree, tierPro, tierScale } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { updateStepStatus } from '$lib/stores/wizard';
|
||||
import { onMount } from 'svelte';
|
||||
import { changeOrganizationTier, changeTierSteps, isUpgrade } from './store';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { OrganizationUsage } from '$lib/sdk/billing';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { sizeToBytes } from '$lib/helpers/sizeConvertion';
|
||||
import { Pill } from '$lib/elements';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
|
||||
let usage: OrganizationUsage = null;
|
||||
let members: Models.MembershipList = null;
|
||||
|
||||
$: if ($changeOrganizationTier.billingPlan === BillingPlan.STARTER && $changeTierSteps) {
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 2, true);
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 3, true);
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 4, true);
|
||||
}
|
||||
|
||||
$: if (
|
||||
$changeOrganizationTier.billingPlan === BillingPlan.SCALE ||
|
||||
$changeOrganizationTier.billingPlan === BillingPlan.PRO
|
||||
) {
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 2, false);
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 3, false);
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 4, false);
|
||||
}
|
||||
|
||||
$: if ($changeOrganizationTier.billingPlan) {
|
||||
$isUpgrade = $changeOrganizationTier.billingPlan > $organization.billingPlan;
|
||||
checkOverUsage();
|
||||
}
|
||||
|
||||
function checkOverUsage() {
|
||||
if (!usage) return;
|
||||
const plan = $plansInfo?.get($changeOrganizationTier.billingPlan);
|
||||
|
||||
const totBandwidth = usage?.bandwidth?.length > 0 ? usage.bandwidth[0].value : 0;
|
||||
const totUsers = usage?.users?.length > 0 ? usage.users[0].value : 0;
|
||||
$changeOrganizationTier.limitOverflow = {
|
||||
bandwidth: totBandwidth > plan.bandwidth ? totBandwidth - plan.bandwidth : 0,
|
||||
storage:
|
||||
usage.storageTotal > sizeToBytes(plan.storage, 'GB')
|
||||
? usage.storageTotal - plan.storage
|
||||
: 0,
|
||||
users: totUsers > plan.users ? totUsers - plan.users : 0,
|
||||
executions:
|
||||
usage.executionsTotal > plan.executions
|
||||
? usage.executionsTotal - plan.executions
|
||||
: 0,
|
||||
members: members.total > plan.members ? members.total - (plan.members || Infinity) : 0
|
||||
};
|
||||
if (
|
||||
($changeOrganizationTier.limitOverflow.bandwidth > 0 ||
|
||||
$changeOrganizationTier.limitOverflow.storage > 0 ||
|
||||
$changeOrganizationTier.limitOverflow.users > 0 ||
|
||||
$changeOrganizationTier.limitOverflow.executions > 0 ||
|
||||
$changeOrganizationTier.limitOverflow.members > 0) &&
|
||||
$changeOrganizationTier.billingPlan === BillingPlan.STARTER
|
||||
) {
|
||||
$changeOrganizationTier.isOverLimit = true;
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 5, false);
|
||||
} else {
|
||||
$changeOrganizationTier.isOverLimit = false;
|
||||
$changeTierSteps = updateStepStatus($changeTierSteps, 5, true);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
usage = await sdk.forConsole.billing.listUsage($organization.$id);
|
||||
members = await sdk.forConsole.teams.listMemberships($organization.$id);
|
||||
|
||||
//Select closest tier from starting one
|
||||
// if ($organization.billingPlan === BillingPlan.SCALE) {
|
||||
// $changeOrganizationTier.billingPlan = BillingPlan.PRO;
|
||||
// }
|
||||
// else
|
||||
if ($organization.billingPlan === BillingPlan.PRO) {
|
||||
$changeOrganizationTier.billingPlan = BillingPlan.STARTER;
|
||||
} else if ($organization.billingPlan === BillingPlan.STARTER) {
|
||||
$changeOrganizationTier.billingPlan = BillingPlan.PRO;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleBefore() {
|
||||
if (!$changeOrganizationTier.billingPlan) {
|
||||
throw new Error('Please select a plan.');
|
||||
}
|
||||
if ($changeOrganizationTier.billingPlan === BillingPlan.STARTER) {
|
||||
$changeOrganizationTier.collaborators = [];
|
||||
}
|
||||
}
|
||||
|
||||
$: freePlan = $plansInfo?.get(BillingPlan.STARTER);
|
||||
$: proPlan = $plansInfo?.get(BillingPlan.PRO);
|
||||
$: scalePlan = $plansInfo?.get(BillingPlan.SCALE);
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleBefore}>
|
||||
<svelte:fragment slot="title">Plan selection</svelte:fragment>
|
||||
|
||||
<p class="body-text-1 u-bold common-section">Plan</p>
|
||||
<p class="text u-margin-block-start-4">
|
||||
For more details on our plans, visit our <a
|
||||
class="link"
|
||||
href="http://appwrite.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">pricing page</a
|
||||
>.
|
||||
</p>
|
||||
<ul
|
||||
class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8"
|
||||
style="--p-grid-item-size:16em; --p-grid-item-size-small-screens:16rem; --grid-gap: 1rem;">
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={$changeOrganizationTier.billingPlan}
|
||||
value="tier-0"
|
||||
disabled={$organization.billingPlan === BillingPlan.STARTER}>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierFree.name} - {formatCurrency(freePlan.price)}/month
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">{tierFree.description}</p>
|
||||
</div>
|
||||
<div class:u-opacity-50={disabled}>
|
||||
{#if $organization.billingPlan === BillingPlan.STARTER}
|
||||
<Pill disabled>CURRENT PLAN</Pill>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={$changeOrganizationTier.billingPlan}
|
||||
value="tier-1"
|
||||
disabled={$organization.billingPlan === BillingPlan.PRO}>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierPro.name} - {formatCurrency(proPlan.price)}/month per organization
|
||||
member + extra usage
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierPro.description}
|
||||
</p>
|
||||
</div>
|
||||
<div class:u-opacity-50={disabled}>
|
||||
{#if $organization.billingPlan === BillingPlan.PRO}
|
||||
<Pill disabled>CURRENT PLAN</Pill>
|
||||
{:else if proPlan?.trialDays}
|
||||
<Pill>{proPlan?.trialDays} DAY FREE TRIAL</Pill>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={$changeOrganizationTier.billingPlan}
|
||||
value="tier-2"
|
||||
disabled>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{tierScale.name} - {formatCurrency(scalePlan.price)}/month + extra usage
|
||||
</h4>
|
||||
<p class="u-color-text-gray u-small">
|
||||
{tierScale.description}
|
||||
</p>
|
||||
</div>
|
||||
<div class:u-opacity-50={disabled}>
|
||||
<Pill disabled>COMING SOON</Pill>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
</ul>
|
||||
</WizardStep>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Box, CreditCardBrandImage } from '$lib/components';
|
||||
import { CouponInput } from '$lib/components/billing';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { FormList, InputSelect, InputTextarea } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import {
|
||||
changeOrganizationFinalAction,
|
||||
changeOrganizationTier,
|
||||
feedbackDowngradeOptions,
|
||||
isUpgrade
|
||||
} from './store';
|
||||
|
||||
const plan = $plansInfo.get($changeOrganizationTier.billingPlan);
|
||||
const collaboratorPrice = plan?.addons.member?.price ?? 0;
|
||||
const collaboratorsNumber = $changeOrganizationTier?.collaborators?.length ?? 0;
|
||||
const totalExpences = plan.price + collaboratorPrice * collaboratorsNumber;
|
||||
const today = new Date();
|
||||
|
||||
$: billingPayDate = $isUpgrade
|
||||
? new Date(today.getTime() + 44 * 24 * 60 * 60 * 1000)
|
||||
: $organization.billingCurrentInvoiceDate;
|
||||
|
||||
let coupon: string = null;
|
||||
let couponData: Partial<Coupon> = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
};
|
||||
|
||||
async function fetchCard() {
|
||||
try {
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(
|
||||
$changeOrganizationTier.paymentMethodId
|
||||
);
|
||||
return card;
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
$: downgradeToStarter = $changeOrganizationTier.billingPlan === BillingPlan.STARTER;
|
||||
$: if (!$isUpgrade) {
|
||||
$changeOrganizationFinalAction = 'Confirm plan change';
|
||||
}
|
||||
|
||||
$: if (plan?.trialDays) {
|
||||
$changeOrganizationFinalAction = 'Start trial';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if downgradeToStarter}
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">Change confirmation</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Your feedback is important to us and helps us improve the services Appwrite offers.
|
||||
Please let us know if there is a specific reason for changing your plan.
|
||||
</svelte:fragment>
|
||||
<FormList>
|
||||
<InputSelect
|
||||
id="reason"
|
||||
label="What made you decide to change your plan?*"
|
||||
placeholder="Select one"
|
||||
required
|
||||
options={feedbackDowngradeOptions}
|
||||
bind:value={$changeOrganizationTier.feedbackDowngradeReason} />
|
||||
<InputTextarea
|
||||
id="comment"
|
||||
label="Your feedback here"
|
||||
placeholder="Enter feedback"
|
||||
bind:value={$changeOrganizationTier.feedbackMessage} />
|
||||
</FormList>
|
||||
</WizardStep>
|
||||
{:else}
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">Details</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Confirm the details of your new organization{!$plansInfo.get(
|
||||
$changeOrganizationTier.billingPlan
|
||||
)?.trialDays
|
||||
? '.'
|
||||
: ' and start your free trial.'}
|
||||
</svelte:fragment>
|
||||
|
||||
<p class="body-text-1 u-bold">Organization name</p>
|
||||
<div class="u-flex u-gap-8 u-cross-center u-margin-block-start-8">
|
||||
<p class="text">{$organization.name}</p>
|
||||
</div>
|
||||
|
||||
{#if $changeOrganizationTier.billingPlan !== BillingPlan.STARTER}
|
||||
<div class="u-margin-block-start-32">
|
||||
<p class="body-text-1 u-bold">Additional members</p>
|
||||
<p class="text u-margin-block-start-8">{collaboratorsNumber} members</p>
|
||||
</div>
|
||||
<div class="u-margin-block-start-32">
|
||||
<p class="body-text-1 u-bold">Payment</p>
|
||||
{#await fetchCard()}
|
||||
<div class="u-flex u-margin-block-start-8">
|
||||
<div class="loader is-small" />
|
||||
</div>
|
||||
{:then card}
|
||||
<span class="u-flex u-cross-center u-gap-8 u-margin-block-start-8">
|
||||
<p class="text">
|
||||
Card ending in {card.last4}
|
||||
</p>
|
||||
<CreditCardBrandImage brand={card.brand}></CreditCardBrandImage>
|
||||
</span>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Box class="u-margin-block-start-32 u-flex u-flex-vertical u-gap-16" radius="small">
|
||||
{#if $changeOrganizationTier.billingPlan !== BillingPlan.STARTER}
|
||||
<CouponInput
|
||||
bind:coupon
|
||||
bind:couponData
|
||||
on:validation={(e) => ($changeOrganizationTier.couponCode = e.detail.code)} />
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">{plan.name} plan</p>
|
||||
<p class="text">{formatCurrency(plan.price)}</p>
|
||||
</span>
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Additional members ({collaboratorsNumber})</p>
|
||||
<p class="text">{formatCurrency(collaboratorPrice * collaboratorsNumber)}</p>
|
||||
</span>
|
||||
{#if couponData?.status === 'active'}
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Credits applied ({couponData.credits})</p>
|
||||
<p class="text">-{formatCurrency(couponData.credits)}</p>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{@const estimatedTotal =
|
||||
couponData?.status === 'active'
|
||||
? totalExpences - couponData.credits >= 0
|
||||
? totalExpences - couponData.credits
|
||||
: 0
|
||||
: totalExpences}
|
||||
<div class="u-sep-block-start" />
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">Estimated total</p>
|
||||
<p class="text">
|
||||
{formatCurrency(estimatedTotal)}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<p class="text u-margin-block-start-16">
|
||||
This amount, and any additional usage fees, will be charged on a recurring 30-day
|
||||
billing cycle{!$plansInfo.get($changeOrganizationTier.billingPlan)?.trialDays
|
||||
? ''
|
||||
: ` after your trial period ends on ${toLocaleDate(billingPayDate.toString())}`}.
|
||||
</p>
|
||||
</Box>
|
||||
</WizardStep>
|
||||
{/if}
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components';
|
||||
import { Button, Form, FormList, InputEmail } from '$lib/elements/forms';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellHead,
|
||||
TableCellText,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/elements/table';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { onMount } from 'svelte';
|
||||
import { changeOrganizationTier } from './store';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
|
||||
const plan = $plansInfo.get($changeOrganizationTier.billingPlan);
|
||||
|
||||
let email: string;
|
||||
|
||||
onMount(async () => {
|
||||
const members = await sdk.forConsole.teams.listMemberships($organization.$id);
|
||||
if (members.total) {
|
||||
$changeOrganizationTier.collaborators = members.memberships
|
||||
.map((m) => {
|
||||
if (m.userEmail !== $user.email) return m.userEmail;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
function addCollaborator() {
|
||||
if (!email) return;
|
||||
if ($changeOrganizationTier.collaborators.includes(email)) return;
|
||||
|
||||
$changeOrganizationTier.collaborators.push(email);
|
||||
$changeOrganizationTier = $changeOrganizationTier;
|
||||
email = '';
|
||||
}
|
||||
|
||||
function removeCollaborator(email: string) {
|
||||
$changeOrganizationTier.collaborators = $changeOrganizationTier.collaborators.filter(
|
||||
(collaborator) => collaborator !== email
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">Invites</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Invite team members to collaborate with you in the Appwrite console. Members will have
|
||||
access to all services and payment data within your organization.
|
||||
</svelte:fragment>
|
||||
|
||||
<Alert type="info">
|
||||
{#if $changeOrganizationTier.billingPlan === BillingPlan.SCALE}
|
||||
You can add unlimited organization members on the {plan.name} plan at no cost. Each member
|
||||
added will receive an email invite to your organization on completion.
|
||||
{:else if $changeOrganizationTier.billingPlan === BillingPlan.PRO}
|
||||
You can add unlimited organization members on the {plan.name} plan for
|
||||
<b>{formatCurrency(plan.addons.member.price)} each per month</b>. Each member added will
|
||||
receive an email invite to your organization on completion.
|
||||
{/if}
|
||||
</Alert>
|
||||
|
||||
<div class="u-margin-block-start-24">
|
||||
<Form onSubmit={addCollaborator}>
|
||||
<FormList>
|
||||
<InputEmail
|
||||
label="Email address"
|
||||
id="email"
|
||||
placeholder="Email address"
|
||||
bind:value={email}>
|
||||
<Button secondary submit>Add</Button>
|
||||
</InputEmail>
|
||||
</FormList>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{#if $changeOrganizationTier?.collaborators?.length}
|
||||
<div class="u-margin-block-start-24">
|
||||
<Table noStyles noMargin>
|
||||
<TableHeader>
|
||||
<TableCellHead>Collaborator</TableCellHead>
|
||||
{#if $changeOrganizationTier.billingPlan === BillingPlan.PRO}
|
||||
<TableCellHead width={80}>Cost</TableCellHead>
|
||||
{/if}
|
||||
<TableCellHead width={40} />
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each $changeOrganizationTier.collaborators as collaborator}
|
||||
<TableRow>
|
||||
<TableCellText title="collaborator">{collaborator}</TableCellText>
|
||||
{#if $changeOrganizationTier.billingPlan === BillingPlan.PRO}
|
||||
<TableCellText title="cost">{formatCurrency(15)}</TableCellText>
|
||||
{/if}
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
style="--button-size:1.5rem;"
|
||||
aria-label="remove collaborator"
|
||||
on:click={() => removeCollaborator(collaborator)}>
|
||||
<span class="icon-x" aria-hidden="true" />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/if}
|
||||
</WizardStep>
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { FormList, InputNumber } from '$lib/elements/forms';
|
||||
import InputChoice from '$lib/elements/forms/inputChoice.svelte';
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { changeOrganizationTier } from './store';
|
||||
import type { PaymentList, PaymentMethodData } from '$lib/sdk/billing';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { initializeStripe, isStripeInitialized, submitStripeCard } from '$lib/stores/stripe';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { showUsageRatesModal } from '$lib/stores/billing';
|
||||
import { PaymentBoxes } from '$lib/components/billing';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let methods: PaymentList;
|
||||
let filteredMethods: PaymentMethodData[];
|
||||
let name: string;
|
||||
let budgetEnabled = false;
|
||||
let initialPaymentMethodId: string;
|
||||
onMount(async () => {
|
||||
methods = await sdk.forConsole.billing.listPaymentMethods();
|
||||
filteredMethods = methods?.paymentMethods.filter((method) => !!method?.last4);
|
||||
|
||||
initialPaymentMethodId =
|
||||
$organization?.paymentMethodId ??
|
||||
$organization?.backupPaymentMethodId ??
|
||||
filteredMethods[0]?.$id ??
|
||||
null;
|
||||
$changeOrganizationTier.paymentMethodId = initialPaymentMethodId;
|
||||
$changeOrganizationTier.billingBudget = $organization?.billingBudget;
|
||||
budgetEnabled = !!$organization?.billingBudget;
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if ($changeOrganizationTier.billingBudget < 0) {
|
||||
throw new Error('Budget cannot be negative');
|
||||
}
|
||||
if ($changeOrganizationTier.paymentMethodId) {
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(
|
||||
$changeOrganizationTier.paymentMethodId
|
||||
);
|
||||
if (!card?.last4) {
|
||||
throw new Error(
|
||||
'The payment method you selected is not valid. Please select a different one.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const method = await submitStripeCard(name, $page?.params?.organization ?? null);
|
||||
const card = await sdk.forConsole.billing.getPaymentMethod(method.$id);
|
||||
if (card?.last4) {
|
||||
$changeOrganizationTier.paymentMethodId = method.$id;
|
||||
} else {
|
||||
throw new Error(
|
||||
'The payment method you selected is not valid. Please select a different one.'
|
||||
);
|
||||
}
|
||||
|
||||
invalidate(Dependencies.PAYMENT_METHODS);
|
||||
} catch (e) {
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($changeOrganizationTier.paymentMethodId === null && !$isStripeInitialized) {
|
||||
initializeStripe();
|
||||
}
|
||||
|
||||
$: if ($changeOrganizationTier.paymentMethodId) {
|
||||
isStripeInitialized.set(false);
|
||||
}
|
||||
|
||||
$: if (!budgetEnabled) {
|
||||
$changeOrganizationTier.billingBudget = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<WizardStep beforeSubmit={handleSubmit}>
|
||||
<svelte:fragment slot="title">Payment details</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
Confirm the payment method for your organization.
|
||||
</svelte:fragment>
|
||||
|
||||
<FormList>
|
||||
<PaymentBoxes
|
||||
methods={filteredMethods}
|
||||
bind:name
|
||||
bind:group={$changeOrganizationTier.paymentMethodId} />
|
||||
|
||||
<InputChoice
|
||||
type="switchbox"
|
||||
id="budget"
|
||||
label="Enable budget cap"
|
||||
tooltip="If enabled, you will be notified by email when your organization spend reaches 75% of the cap you set. Update your budget cap alerts in organization Settings."
|
||||
fullWidth
|
||||
bind:value={budgetEnabled}>
|
||||
<p class="text">
|
||||
Restrict your resource usage by setting a budget cap. <button
|
||||
class="link"
|
||||
type="button"
|
||||
on:click={() => ($showUsageRatesModal = true)}>
|
||||
Learn more about usage rates</button
|
||||
>.
|
||||
</p>
|
||||
{#if budgetEnabled}
|
||||
<div class="u-margin-block-start-16">
|
||||
<InputNumber
|
||||
id="budget"
|
||||
label="Budget cap (USD)"
|
||||
placeholder="0"
|
||||
bind:value={$changeOrganizationTier.billingBudget} />
|
||||
</div>
|
||||
{/if}
|
||||
</InputChoice>
|
||||
</FormList>
|
||||
</WizardStep>
|
||||
@@ -1,70 +0,0 @@
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import type { WizardStepsType } from '$lib/layout/wizard.svelte';
|
||||
import { type Tier } from '$lib/stores/billing';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const changeTierSteps = writable<WizardStepsType>(new Map());
|
||||
export const isUpgrade = writable<boolean>(false);
|
||||
export const changeOrganizationFinalAction = writable<string>('Create');
|
||||
|
||||
export const changeOrganizationTier = writable<{
|
||||
billingPlan: Tier;
|
||||
paymentMethodId: string;
|
||||
billingAddressId: string;
|
||||
billingBudget?: number;
|
||||
collaborators?: string[];
|
||||
isOverLimit?: boolean;
|
||||
limitOverflow?: {
|
||||
bandwidth?: number;
|
||||
executions?: number;
|
||||
storage?: number;
|
||||
users?: number;
|
||||
members?: number;
|
||||
};
|
||||
taxId?: string;
|
||||
feedbackMessage?: string;
|
||||
feedbackDowngradeReason?: string;
|
||||
couponCode?: string;
|
||||
}>({
|
||||
billingPlan: BillingPlan.PRO,
|
||||
paymentMethodId: null,
|
||||
collaborators: [],
|
||||
isOverLimit: false,
|
||||
billingAddressId: null,
|
||||
taxId: null
|
||||
});
|
||||
|
||||
export const feedbackDowngradeOptions = [
|
||||
{
|
||||
value: 'availableFeatures',
|
||||
label: "The available features don't meet my needs"
|
||||
},
|
||||
{
|
||||
value: 'traction',
|
||||
label: "My project isn't getting traction"
|
||||
},
|
||||
{
|
||||
value: 'bugs',
|
||||
label: 'I experienced bugs or unexpected outages while using the console'
|
||||
},
|
||||
{
|
||||
value: 'starter',
|
||||
label: 'The Starter plan is enough for my projects'
|
||||
},
|
||||
{
|
||||
value: 'budget',
|
||||
label: "I don't have the budget"
|
||||
},
|
||||
{
|
||||
value: 'tryOut',
|
||||
label: 'I just wanted to try it out'
|
||||
},
|
||||
{
|
||||
value: 'alternative',
|
||||
label: 'I found an alternative/competitor to meet my needs'
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: 'Other'
|
||||
}
|
||||
];
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { WizardStep } from '$lib/layout';
|
||||
import { tierToPlan } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import PlanExcess from './planExcess.svelte';
|
||||
import { changeOrganizationTier } from './store';
|
||||
</script>
|
||||
|
||||
<WizardStep>
|
||||
<svelte:fragment slot="title">
|
||||
Your usage exceeds the limits of the {tierToPlan($organization.billingPlan).name} plan</svelte:fragment>
|
||||
<svelte:fragment slot="subtitle">
|
||||
View the current usage for <b>{$organization.name}</b> and where you will exceed the limits of
|
||||
your new plan.
|
||||
</svelte:fragment>
|
||||
|
||||
<PlanExcess
|
||||
excess={$changeOrganizationTier.limitOverflow}
|
||||
currentTier={$changeOrganizationTier.billingPlan} />
|
||||
</WizardStep>
|
||||
@@ -1,20 +1,19 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { registerUserStep } from '../steps/account';
|
||||
import { createFreeProject } from '../steps/free-project';
|
||||
import { enterAddress, enterCreditCard } from '../steps/pro-project';
|
||||
import { enterCreditCard } from '../steps/pro-project';
|
||||
|
||||
test('upgrade - free tier', async ({ page }) => {
|
||||
await registerUserStep(page);
|
||||
await createFreeProject(page);
|
||||
await test.step('upgrade project', async () => {
|
||||
await page.getByRole('button', { name: 'upgrade' }).click();
|
||||
await page.getByRole('link', { name: 'upgrade' }).click();
|
||||
await page.waitForURL('/console/organization-**/change-plan');
|
||||
await page.locator('input[value="tier-1"]').click();
|
||||
await page.getByRole('button', { name: 'next' }).click();
|
||||
await page.getByRole('button', { name: ' Add', exact: true }).click();
|
||||
await enterCreditCard(page);
|
||||
await enterAddress(page);
|
||||
// skip members
|
||||
await page.getByRole('button', { name: 'next' }).click();
|
||||
await page.getByRole('button', { name: 'create' }).click();
|
||||
await page.getByRole('button', { name: 'change plan' }).click();
|
||||
await page.waitForURL('/console/organization-**');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,15 +13,7 @@ export async function enterCreditCard(page: Page) {
|
||||
await stripe.locator('id=Field-expiryInput').fill('1250');
|
||||
await stripe.locator('id=Field-cvcInput').fill('123');
|
||||
await stripe.locator('id=Field-countryInput').selectOption('DE');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
}
|
||||
|
||||
export async function enterAddress(page: Page) {
|
||||
await page.locator('id=country').selectOption('US');
|
||||
await page.locator('id=address').fill('123 Test St');
|
||||
await page.locator('id=city').fill('Test City');
|
||||
await page.locator('id=state').fill('Test State');
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
}
|
||||
|
||||
export async function createProProject(page: Page): Promise<Metadata> {
|
||||
@@ -31,12 +23,11 @@ export async function createProProject(page: Page): Promise<Metadata> {
|
||||
await page.locator('id=name').fill('test org');
|
||||
await page.locator('id=plan').selectOption('tier-1');
|
||||
await page.getByRole('button', { name: 'get started' }).click();
|
||||
await page.waitForURL('/console/create-organization**');
|
||||
await page.getByRole('button', { name: ' Add', exact: true }).click();
|
||||
await enterCreditCard(page);
|
||||
await enterAddress(page);
|
||||
// skip members
|
||||
await page.getByRole('button', { name: 'next' }).click();
|
||||
// start pro trial
|
||||
await page.getByRole('button', { name: 'create' }).click();
|
||||
await page.getByRole('button', { name: 'create organization' }).click();
|
||||
await page.waitForURL('/console/organization-**');
|
||||
|
||||
return getOrganizationIdFromUrl(page.url());
|
||||
|
||||
Reference in New Issue
Block a user