Merge pull request #1085 from appwrite/refactor-org-plan-change

refactor: org plan change flow
This commit is contained in:
Torsten Dittmann
2024-05-15 15:22:06 +02:00
committed by GitHub
11 changed files with 479 additions and 32 deletions
+9 -9
View File
@@ -7,8 +7,8 @@
"name": "@appwrite/console",
"dependencies": {
"@appwrite.io/console": "^0.6.1",
"@appwrite.io/pink": "0.16.0",
"@appwrite.io/pink-icons": "0.16.0",
"@appwrite.io/pink": "0.17.0",
"@appwrite.io/pink-icons": "0.17.0",
"@popperjs/core": "^2.11.8",
"@sentry/svelte": "^7.66.0",
"@sentry/tracing": "^7.66.0",
@@ -166,19 +166,19 @@
}
},
"node_modules/@appwrite.io/pink": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@appwrite.io/pink/-/pink-0.16.0.tgz",
"integrity": "sha512-KprOd+36I0w6PC0x6NJclvyJNpCsjAPcrCHL3QixSX0dZ66wqFE6NitF/KiVVCxbuBaNgjrm7oQ0hr7dt1Sm5Q==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@appwrite.io/pink/-/pink-0.17.0.tgz",
"integrity": "sha512-1FkVVqdqU5d0FlGIyLisCoLR3JaqGlQs3+BJydo6Mr9/bRWKcg4O9nw+e1P1Lx8StnVnDEM5CRCQdzyPQSNByw==",
"dependencies": {
"@appwrite.io/pink-icons": "0.16.0",
"@appwrite.io/pink-icons": "0.17.0",
"normalize.css": "^8.0.1",
"the-new-css-reset": "^1.11.2"
}
},
"node_modules/@appwrite.io/pink-icons": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@appwrite.io/pink-icons/-/pink-icons-0.16.0.tgz",
"integrity": "sha512-tWaG5lFXnKzbFbGf4SlxoFYj2yH7kQDejFq+UHdfzBrqteSfZpxIxdNmKHIea1ztcmsat4y+R0ZbVX9iOv+oPw=="
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@appwrite.io/pink-icons/-/pink-icons-0.17.0.tgz",
"integrity": "sha512-JcYjVI+nPO8BTJzKlUH2RKyUYg/3pEy81dr3TrQB0HlsrngDq3akhaNCfnQzLg9Kicdl+Cr4Gq920Ha6zA4FQQ=="
},
"node_modules/@babel/code-frame": {
"version": "7.24.2",
+2 -2
View File
@@ -18,8 +18,8 @@
"e2e": "playwright test tests/e2e"
},
"dependencies": {
"@appwrite.io/pink": "0.16.0",
"@appwrite.io/pink-icons": "0.16.0",
"@appwrite.io/pink": "0.17.0",
"@appwrite.io/pink-icons": "0.17.0",
"@appwrite.io/console": "^0.6.1",
"@popperjs/core": "^2.11.8",
"@sentry/svelte": "^7.66.0",
@@ -28,7 +28,7 @@
: grossCost;
</script>
<Box class="u-margin-block-start-32 u-flex u-flex-vertical u-gap-16" radius="small">
<Box class="u-margin-block-start-32 u-flex u-flex-vertical u-gap-8" radius="small">
<span class="u-flex u-main-space-between">
<p class="text">{currentPlan.name} plan</p>
<p class="text">{formatCurrency(currentPlan.price)}</p>
@@ -68,7 +68,12 @@
}
</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
@@ -90,7 +95,7 @@
</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>
@@ -25,25 +25,25 @@
<div class="u-margin-block-start-24">
{#if selectedTab === 'tier-0'}
<h3 class="u-bold body-text-1">{plan.name} plan</h3>
<ul class="list u-margin-block-start-8">
<li class="list-item">
<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 class="list-item">Limited to 1 organization member</li>
<li class="list-item">{plan.bandwidth}GB bandwidth</li>
<li class="list-item">{plan.storage}GB storage</li>
<li class="list-item">{formatNum(plan.executions)} executions</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="list u-margin-block-start-8">
<li class="list-item">Everything in the Starter plan, plus:</li>
<li class="list-item">Unlimited databases, buckets, functions</li>
<li class="list-item">{plan.bandwidth}GB bandwidth</li>
<li class="list-item">{plan.storage}GB storage</li>
<li class="list-item">{formatNum(plan.executions)} executions</li>
<li class="list-item">Email support</li>
<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>
@@ -4,7 +4,7 @@
import { sdk } from '$lib/stores/sdk';
import { hasStripePublicKey, isCloud } from '$lib/system';
import { onMount } from 'svelte';
import { Card, CreditCardBrandImage } from '..';
import { Alert, Card, CreditCardBrandImage } from '..';
import PaymentModal from './paymentModal.svelte';
import { capitalize } from '$lib/helpers/string';
@@ -27,9 +27,20 @@
});
$: 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"
@@ -73,7 +84,7 @@
</svelte:fragment>
</InputSelectSearch>
{:else}
<Card isDashed style="--p-card-padding: 1rem; --p-card-bg-color: transparent" isTile>
<Card isDashed style="--p-card-padding: 0.75rem; --p-card-bg-color: transparent" isTile>
<div class="u-flex u-main-space-between u-cross-center">
<p>
<span class="icon-exclamation-circle"></span>
+35
View File
@@ -350,3 +350,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'
}
];
+2 -2
View File
@@ -5,8 +5,8 @@
</script>
<header class="wizard-secondary-header">
<div class="u-flex u-main-space-between u-gap-32">
<Heading size={1} tag="h1"><slot /></Heading>
<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>
+1 -1
View File
@@ -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 = {
@@ -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 = $organization.billingPlan;
});
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="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">
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
};
};