Merge pull request #1080 from appwrite/refactor-org-creation

refactor: org creation new flow
This commit is contained in:
Torsten Dittmann
2024-05-22 22:16:25 +02:00
committed by GitHub
80 changed files with 1339 additions and 2088 deletions
+7 -4
View File
@@ -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
View File
@@ -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",
+10 -8
View File
@@ -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>
+4
View File
@@ -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';
@@ -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}
@@ -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
View File
@@ -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'}
+2 -5
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -3
View File
@@ -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'
+35
View File
@@ -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'
}
];
+11 -1
View File
@@ -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}
+6 -6
View File
@@ -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>
+7
View File
@@ -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)) {
+9 -6
View File
@@ -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}
+6 -5
View File
@@ -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',
+8 -6
View File
@@ -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>
+3 -5
View File
@@ -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'
+4
View File
@@ -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';
+3 -9
View File
@@ -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}
+3 -5
View File
@@ -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>
+2 -2
View File
@@ -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 = {
+9 -4
View File
@@ -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);
}
}
+2 -3
View File
@@ -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 />
+3 -8
View File
@@ -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}
@@ -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>
@@ -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>
+5 -6
View File
@@ -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-**');
});
});
+4 -13
View File
@@ -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());