Merge pull request #694 from appwrite/feat-new-billing-onboarding

feat: new billing onboarding flow
This commit is contained in:
Eldad A. Fux
2023-12-29 19:27:27 +01:00
committed by GitHub
12 changed files with 168 additions and 80 deletions
@@ -2,6 +2,7 @@
import { FormList, InputText } from '$lib/elements/forms';
import { onDestroy, onMount } from 'svelte';
import { CreditCardBrandImage, RadioBoxes } from '..';
import { unmountPaymentElement } from '$lib/stores/stripe';
export let methods: Record<string, unknown>[];
export let group: string;
@@ -33,6 +34,7 @@
onDestroy(() => {
observer.disconnect();
unmountPaymentElement();
});
$: if (element) {
+8 -1
View File
@@ -23,6 +23,7 @@
let classes: string = undefined;
export { classes as class };
export let actions: MultiActionArray = [];
export let submissionLoader = false;
const isSubmitting = hasContext('form')
? getContext<FormContext>('form').isSubmitting
@@ -77,6 +78,12 @@
aria-label={ariaLabel}
type={submit ? 'submit' : 'button'}
use:multiAction={actions}>
<slot />
{#if $isSubmitting && submissionLoader}
<span
class="loader is-small"
style:--p-loader-base-full-color="transparent"
aria-hidden="true" />
{/if}
<slot isSubmitting={$isSubmitting} />
</button>
{/if}
+1
View File
@@ -122,6 +122,7 @@
} else {
$wizard.step--;
}
wizard.setInterceptor(null);
trackEvent('wizard_back');
}
+8 -1
View File
@@ -8,10 +8,17 @@
function handleSubmit() {
dispatch('exit');
show = false;
}
</script>
<Modal title="Exit Process" bind:show onSubmit={handleSubmit} icon="exclamation" state="warning">
<Modal
title="Exit Process"
bind:show
onSubmit={handleSubmit}
icon="exclamation"
state="warning"
headerDivider={false}>
<p>
Are you sure you want to exit from <slot />? All data will be deleted. This action is
irreversible.
+2 -19
View File
@@ -8,7 +8,7 @@ import { cachedStore } from '$lib/helpers/cache';
import { Query, type Models } from '@appwrite.io/console';
import { headerAlert } from './headerAlert';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import { diffDays, toLocaleDate } from '$lib/helpers/date';
import { diffDays } from '$lib/helpers/date';
import { addNotification, notifications } from './notifications';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
@@ -166,24 +166,6 @@ export function calculateTrialDay(org: Organization) {
return days;
}
export function checkForTrialEnding(org: Organization) {
const days = calculateTrialDay(org);
if (localStorage.getItem('trialEndingNotification') === 'true' || !days) return;
else if (days <= 5) {
addNotification({
type: 'info',
isHtml: true,
message: `<b>We hope you've been enjoying the ${
tierToPlan(org.billingPlan).name
} plan.</b>
You will be billed on a recurring 30-day cycle after your trial period ends on <b>${toLocaleDate(
org.billingStartDate
)}</b>`
});
localStorage.setItem('trialEndingNotification', 'true');
}
}
export async function checkForUsageLimit(org: Organization) {
if (!org?.billingLimits) {
readOnly.set(false);
@@ -295,6 +277,7 @@ export async function checkForFreeOrgOverflow(orgs: Models.TeamList<Record<strin
export async function checkForPostReleaseProModal(orgs: Models.TeamList<Record<string, unknown>>) {
if (!orgs?.teams?.length) return;
if (orgs.total > orgs.teams.length) return; // if the total is greater that the free orgs it means that there are pro orgs
const modalTime = localStorage.getItem('postReleaseProModal');
const now = Date.now();
// show the modal if it was never shown
+7 -1
View File
@@ -37,7 +37,13 @@ export async function initializeStripe() {
paymentElement.mount('#payment-element');
}
// TODO: fix redirect
export async function unmountPaymentElement() {
isStripeInitialized.set(false);
paymentElement?.unmount();
clientSecret = null;
paymentMethod = null;
elements = null;
}
export async function submitStripeCard(name: string, urlRoute?: string) {
try {
+6 -2
View File
@@ -31,14 +31,18 @@ function createWizardStore() {
return {
subscribe,
set,
start: (component: typeof SvelteComponent<unknown>, media: string = null) =>
start: (
component: typeof SvelteComponent<unknown>,
media: string = null,
step: number = 1
) =>
update((n) => {
n.show = true;
n.component = component;
n.interceptor = null;
n.interceptorNotificationEnabled = true;
n.media = media;
n.step = 1;
n.step = step;
n.cover = null;
n.nextDisabled = false;
n.finalAction = null;
-2
View File
@@ -18,7 +18,6 @@
checkForUsageLimit,
checkPaymentAuthorizationRequired,
calculateTrialDay,
checkForTrialEnding,
paymentExpired,
checkForFreeOrgOverflow,
checkForPostReleaseProModal,
@@ -282,7 +281,6 @@
if (!org) return;
if (isCloud) {
calculateTrialDay(org);
checkForTrialEnding(org);
await paymentExpired(org);
await checkForUsageLimit(org);
checkForMarkedForDeletion(org);
@@ -204,4 +204,5 @@
title="Change plan"
steps={$changeTierSteps}
finalAction={$changeOrganizationFinalAction}
on:exit={onFinish} />
on:exit={onFinish}
confirmExit />
@@ -28,6 +28,17 @@
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,
@@ -64,6 +75,13 @@
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}`);
@@ -71,12 +89,7 @@
type: 'success',
message: `${$createOrganization.name ?? 'Organization'} has been created`
});
trackEvent(Submit.OrganizationCreate, {
customId: !!$createOrganization.id,
plan: tierToPlan($createOrganization.billingPlan)?.name,
budget_cap_enabled: !!$createOrganization?.billingBudget,
members_invited: $createOrganization?.collaborators?.length
});
wizard.hide();
if (org.billingPlan === BillingPlan.PRO) {
wizard.showCover(HoodieCover);
@@ -129,4 +142,5 @@
title="Create organization"
steps={$createOrgSteps}
finalAction={$createOrganizationFinalAction}
on:exit={onFinish} />
on:exit={onFinish}
confirmExit />
+106 -44
View File
@@ -2,11 +2,11 @@
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Card } from '$lib/components';
import { Card, Heading } from '$lib/components';
import CustomId from '$lib/components/customId.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Pill } from '$lib/elements';
import { Button, Form, InputText } from '$lib/elements/forms';
import { Button, Form, InputSelect, InputText } from '$lib/elements/forms';
import FormList from '$lib/elements/forms/formList.svelte';
import { Container } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
@@ -16,10 +16,18 @@
import { ID } from '@appwrite.io/console';
import { onMount } from 'svelte';
import CreateOrganizationCloud from '../createOrganizationCloud.svelte';
import { tierToPlan, type Tier } from '$lib/stores/billing';
import { createOrganization } from '../wizard/cloudOrganization/store';
let name: string;
let id: string;
let showCustomId = false;
let plan: Tier;
const options = [
{ value: BillingPlan.STARTER, label: 'Starter - $0/month' },
{ value: BillingPlan.PRO, label: 'Pro - $15/month + add-ons' }
];
onMount(() => {
if (isCloud) {
@@ -32,66 +40,120 @@
}
});
async function createProject() {
try {
const org = await createOrganization();
const project = await sdk.forConsole.projects.create(
id ?? ID.unique(),
name,
org.$id,
isCloud ? 'fra' : 'default'
);
await invalidate(Dependencies.ACCOUNT);
goto(`/console/project-${project.$id}`);
trackEvent(Submit.ProjectCreate, {
customId: !!id,
teamId: org.$id
});
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.ProjectCreate);
}
}
async function createOrganization() {
async function handleSubmit() {
const orgName = name?.length ? name : 'Personal Projects';
if (isCloud) {
return await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal Projects',
BillingPlan.STARTER,
null,
null
);
} else return await sdk.forConsole.teams.create(ID.unique(), 'Personal Projects');
if (plan === BillingPlan.STARTER) {
try {
const org = await sdk.forConsole.billing.createOrganization(
id ?? ID.unique(),
orgName,
plan,
null,
null
);
trackEvent(Submit.OrganizationCreate, {
customId: !!id,
plan: tierToPlan(plan)?.name
});
await invalidate(Dependencies.ACCOUNT);
await goto(`/console/organization-${org.$id}`);
addNotification({
message: `${orgName} organization successfully created`,
type: 'success'
});
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.OrganizationCreate);
}
} else {
wizard.start(CreateOrganizationCloud, null, 2);
$createOrganization.name = orgName;
$createOrganization.billingPlan = plan;
$createOrganization.id = id;
}
} else {
try {
const org = await sdk.forConsole.teams.create(id ?? ID.unique(), orgName);
await invalidate(Dependencies.ACCOUNT);
await goto(`/console/organization-${org.$id}`);
addNotification({
message: `${orgName} organization successfully created`,
type: 'success'
});
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.OrganizationCreate);
}
}
}
</script>
<Container overlapCover size="large">
<Card>
<Form onSubmit={createProject}>
<FormList>
<Heading size="4" tag="h2">Create a new organization</Heading>
<Form onSubmit={handleSubmit}>
<FormList gap={16}>
<InputText
id="name"
label="Project name"
placeholder="First Appwrite Project"
required
label="Name"
placeholder="Organization name"
hideRequired
bind:value={name} />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" /><span class="text">
Project ID
Organization ID
</span>
</Pill>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Project" isProject bind:id />
<CustomId bind:show={showCustomId} name="Organization" isProject bind:id />
{/if}
<Button fullWidth submit disabled={name === ''} event="create_project">
Create project
{#if isCloud}
<div class="u-margin-block-start-8">
<h3><b>Plan</b></h3>
<p>
For more details on our plans, visit our <Button
link
external
href="https://appwrite.io/pricing">pricing page</Button
>.
</p>
<FormList class="u-margin-block-start-8">
<InputSelect
placeholder="Select a plan"
label="Select plan"
showLabel={false}
id="plan"
required
hideRequired
{options}
bind:value={plan} />
</FormList>
</div>
{/if}
<Button
fullWidth
submit
disabled={isCloud && !plan}
event="create_organization"
submissionLoader
let:isSubmitting>
{#if isSubmitting}
Creating your first organization
{:else}
Get started
{/if}
</Button>
</FormList>
</Form>
+5 -2
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { Cover, CoverTitle } from '$lib/layout';
import { Heading } from '$lib/components';
import { Cover } from '$lib/layout';
</script>
<Cover size="large">
<CoverTitle>Let's create your first project</CoverTitle>
<div class="u-flex u-cross-center u-main-center u-width-full-line">
<Heading size="1" tag="h1" trimmed={false}>Welcome to Appwrite</Heading>
</div>
</Cover>