feat: design fixes, upgrade flow

This commit is contained in:
Arman
2024-05-13 15:51:16 +02:00
parent 34103f9787
commit 9ff105a654
10 changed files with 266 additions and 192 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>
@@ -73,7 +73,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>
@@ -3,15 +3,23 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { LabelCard } from '$lib/components';
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 } from '$lib/constants';
import { Button, Form, FormList, InputTags, Label } from '$lib/elements/forms';
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,
@@ -19,16 +27,23 @@
WizardSecondaryFooter,
WizardSecondaryHeader
} from '$lib/layout';
import type { Coupon, PaymentList } from '$lib/sdk/billing';
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 {
organization,
organizationList,
type Organization,
type OrganizationList
} 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
);
@@ -41,21 +56,29 @@
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let billingPlan: Tier = $organization.billingPlan;
let paymentMethodId: string;
let collaborators: 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;
let orgList: OrganizationList;
onMount(async () => {
if ($page.url.searchParams.has('coupon')) {
const coupon = $page.url.searchParams.get('coupon');
@@ -70,9 +93,6 @@
};
}
}
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 && plan in BillingPlan) {
@@ -80,183 +100,163 @@
}
}
billingPlan = $organization.billingPlan;
if (anyOrgFree && billingPlan === BillingPlan.PRO) {
orgList = await sdk.forConsole.billing.listOrganization();
}
});
async function loadPaymentMethods() {
methods = await sdk.forConsole.billing.listPaymentMethods();
paymentMethodId = methods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
paymentMethodId =
$organization?.paymentMethodId ??
methods.paymentMethods.find((method) => !!method?.last4)?.$id ??
null;
}
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'
}
];
let feedbackDowngradeReason: string;
let feedbackMessage: string;
async function update() {
//Downgrade
async function handleSubmit() {
if (billingPlan === BillingPlan.STARTER) {
try {
await sdk.forConsole.billing.updatePlan(
$organization.$id,
billingPlan,
paymentMethodId,
null
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)
);
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 ?? ''
})
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> 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
);
}
} else {
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) {
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);
}
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
);
}
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>
@@ -266,12 +266,20 @@
<WizardSecondaryContainer>
<WizardSecondaryHeader href={previousPage}>Change plan</WizardSecondaryHeader>
<WizardSecondaryContent>
<Form bind:this={formComponent} onSubmit={update} bind:isSubmitting>
<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>
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;">
@@ -346,6 +354,22 @@
</Button>
{/if}
{/if}
{#if !isUpgrade && 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}
@@ -365,7 +389,7 @@
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting}>
disabled={$isSubmitting || isButtonDisabled}>
Change plan
</Button>
</WizardSecondaryFooter>
@@ -0,0 +1,10 @@
import { Dependencies } from '$lib/constants';
export const load = async ({ depends, parent }) => {
const { members } = await parent();
depends(Dependencies.ORGANIZATION);
return {
members
};
};