fix: create org

This commit is contained in:
Torsten Dittmann
2025-03-12 20:36:48 +04:00
parent 2b07e688a9
commit eafb780f4c
9 changed files with 362 additions and 375 deletions
@@ -1,14 +1,16 @@
<script lang="ts">
import { FormList, InputChoice, InputNumber } from '$lib/elements/forms';
import { 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';
import type { Coupon, PlansMap } from '$lib/sdk/billing';
import { type Tier } from '$lib/stores/billing';
import { Card, Divider, Layout, Typography } from '@appwrite.io/pink-svelte';
import { CreditsApplied } from '.';
export let billingPlan: Tier;
export let collaborators: string[];
export let couponData: Partial<Coupon>;
export let plans: PlansMap;
export let billingBudget: number;
export let fixedCoupon = false; // If true, the coupon cannot be removed
export let isDowngrade = false;
@@ -18,7 +20,7 @@
let budgetEnabled = false;
$: currentPlan = $plansInfo.get(billingPlan);
$: currentPlan = plans.get(billingPlan);
$: extraSeatsCost = 0; // 0 untile trial period later replace (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: grossCost = currentPlan.price + extraSeatsCost;
$: estimatedTotal =
@@ -32,51 +34,44 @@
);
</script>
<section
class="card u-flex u-flex-vertical u-gap-8"
style:--p-card-padding="1.5rem"
style:--p-card-border-radius="var(--border-radius-small)">
<slot />
<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" class:u-bold={isDowngrade}>Additional seats ({collaborators?.length})</p>
<p class="text" class:u-bold={isDowngrade}>
{formatCurrency(extraSeatsCost)}
</p>
</span>
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<div class="u-sep-block-start" />
<span class="u-flex u-main-space-between">
<p class="text">
Upcoming charge<br /><span class="u-color-text-gray"
>Due on {!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span>
</p>
<p class="text">
{formatCurrency(estimatedTotal)}
</p>
</span>
<p class="text u-margin-block-start-16">
You'll pay <span class="u-bold">{formatCurrency(estimatedTotal)}</span> now, with your first
billing cycle starting on
<span class="u-bold"
>{!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span
>. {#if couponData?.status === 'active'}Once your credits run out, you'll be charged
<span class="u-bold">{formatCurrency(currentPlan.price)}</span> plus usage fees every 30
days.
<Card.Base padding="s">
<Layout.Stack>
<slot />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text>{currentPlan.name} plan</Typography.Text>
<Typography.Text>{formatCurrency(currentPlan.price)}</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>Additional seats ({collaborators?.length})</Typography.Text>
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{formatCurrency(extraSeatsCost)}</Typography.Text>
</Layout.Stack>
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
</p>
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text>
Upcoming charge<br />
Due on {!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</Typography.Text>
<Typography.Text>{formatCurrency(estimatedTotal)}</Typography.Text>
</Layout.Stack>
<Typography.Text>
You'll pay <b>{formatCurrency(estimatedTotal)}</b> now, with your first billing cycle
starting on
<b
>{!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</b
>. {#if couponData?.status === 'active'}Once your credits run out, you'll be charged
<b>{formatCurrency(currentPlan.price)}</b> plus usage fees every 30 days.
{/if}
</Typography.Text>
<FormList class="u-margin-block-start-24">
<InputChoice
type="switchbox"
id="budget"
@@ -95,5 +90,5 @@
</div>
{/if}
</InputChoice>
</FormList>
</section>
</Layout.Stack>
</Card.Base>
+19 -17
View File
@@ -74,23 +74,25 @@
</LabelCard>
</li>
<li>
<LabelCard name="plan" bind:group={billingPlan} value={BillingPlan.SCALE} padding={1.5}>
<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">
{tierScale.name}
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{tierScale.description}
</p>
<p>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</p>
</div>
</svelte:fragment>
<LabelCard
name="plan"
bind:group={billingPlan}
value={BillingPlan.SCALE}
title={tierScale.name}
padding="m">
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
<h4 class="body-text-2 u-bold">
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{tierScale.description}
</p>
<p>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</p>
</div>
</LabelCard>
</li>
</ul>
@@ -41,7 +41,6 @@
});
$: filteredMethods = methods?.paymentMethods?.filter((method) => !!method?.last4);
$: selectedPaymentMethod = methods?.paymentMethods?.find((method) => method.$id === value);
</script>
@@ -10,77 +10,37 @@
SelectPaymentMethod
} from '$lib/components/billing';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, FormList, InputTags, InputText, Label } from '$lib/elements/forms';
import {
WizardSecondaryContainer,
WizardSecondaryContent,
WizardSecondaryFooter
} from '$lib/layout';
import { Button, Form, InputTags, InputText } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import type { Coupon, PaymentList } from '$lib/sdk/billing';
import { tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { organizationList, type Organization } from '$lib/stores/organization';
import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { ID } from '@appwrite.io/console';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Icon } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import { Fieldset, Icon, Link, Typography } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
$: anyOrgFree = $organizationList.teams?.some(
(org) => (org as Organization)?.billingPlan === BillingPlan.FREE
);
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
export let data;
let selectedPlan: BillingPlan = data.plan as BillingPlan;
let selectedCoupon: Partial<Coupon> | null = data.coupon;
let previousPage: string = base;
let showExitModal = false;
afterNavigate(({ from }) => {
previousPage = from?.url?.pathname || previousPage;
});
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let name: string;
let billingPlan: BillingPlan = BillingPlan.FREE;
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;
}
}
afterNavigate(({ from }) => {
previousPage = from?.url?.pathname || previousPage;
});
async function loadPaymentMethods() {
@@ -92,7 +52,7 @@
try {
let org: Organization;
if (billingPlan === BillingPlan.FREE) {
if (selectedPlan === BillingPlan.FREE) {
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
name,
@@ -104,7 +64,7 @@
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
name,
billingPlan,
selectedPlan,
paymentMethodId,
null
);
@@ -115,8 +75,8 @@
}
//Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
@@ -141,7 +101,7 @@
}
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(billingPlan)?.name,
plan: tierToPlan(selectedPlan)?.name,
budget_cap_enabled: !!billingBudget,
members_invited: collaborators?.length
});
@@ -162,7 +122,7 @@
}
}
$: if (billingPlan !== BillingPlan.FREE) {
$: if (selectedPlan !== BillingPlan.FREE) {
loadPaymentMethods();
}
</script>
@@ -171,57 +131,60 @@
<title>Create organization - Appwrite</title>
</svelte:head>
<WizardSecondaryContainer bind:showExitModal href={previousPage} confirmExit>
<svelte:fragment slot="title">Create organization</svelte:fragment>
<WizardSecondaryContent>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<FormList>
<InputText
bind:value={name}
label="Organization name"
placeholder="Enter organization name"
id="name"
required />
</FormList>
<Label class="label u-margin-block-start-16">Select plan</Label>
<p class="text">
<Wizard title="Create organization" href={previousPage} bind:showExitModal confirmExit>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Fieldset legend="Options">
<InputText
bind:value={name}
label="Organization name"
placeholder="Enter organization name"
id="name"
required />
</Fieldset>
<Fieldset legend="Select plan">
<Typography.Text>
For more details on our plans, visit our
<Button href="https://appwrite.io/pricing" external link>pricing page</Button>.
</p>
<PlanSelection bind:billingPlan {anyOrgFree} isNewOrg />
{#if billingPlan !== BillingPlan.FREE}
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
</Typography.Text>
<PlanSelection
bind:billingPlan={selectedPlan}
anyOrgFree={data.hasFreeOrganizations}
isNewOrg />
</Fieldset>
{#if selectedPlan !== BillingPlan.FREE}
<Fieldset legend="Invite members">
<InputTags
bind:tags={collaborators}
label="Invite members by email"
popover={Default}
placeholder="Enter email address(es)"
validityRegex={emailRegex}
validityMessage="Invalid email address"
id="members" />
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId
></SelectPaymentMethod>
{#if !couponData?.code}
<Button text on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</Fieldset>
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId />
{#if !selectedCoupon?.code}
<Button text on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</Form>
<svelte:fragment slot="aside">
{#if billingPlan !== BillingPlan.FREE}
<EstimatedTotalBox
{billingPlan}
{collaborators}
bind:couponData
bind:billingBudget />
{:else}
<PlanComparisonBox />
{/if}
</svelte:fragment>
</WizardSecondaryContent>
<WizardSecondaryFooter>
{/if}
</Form>
<svelte:fragment slot="aside">
{#if selectedPlan !== BillingPlan.FREE}
<EstimatedTotalBox
billingPlan={selectedPlan}
{collaborators}
plans={data.plansInfo}
bind:couponData={selectedCoupon}
bind:billingBudget />
{:else}
<PlanComparisonBox />
{/if}
</svelte:fragment>
<svelte:fragment slot="footer">
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
<Button
fullWidthMobile
@@ -229,7 +192,7 @@
disabled={$isSubmitting}>
Create organization
</Button>
</WizardSecondaryFooter>
</WizardSecondaryContainer>
</svelte:fragment>
</Wizard>
<ValidateCreditModal bind:show={showCreditModal} bind:couponData />
<ValidateCreditModal bind:show={showCreditModal} bind:couponData={selectedCoupon} />
@@ -0,0 +1,46 @@
import { BillingPlan } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
import type { Coupon } from '$lib/sdk/billing';
import type { Organization } from '$lib/stores/organization';
export const load: PageLoad = async ({ url, parent }) => {
const { organizations } = await parent();
const coupon = await getCoupon(url);
let plan = getPlanFromUrl(url);
const hasFreeOrganizations = organizations.teams?.some(
(org) => (org as Organization)?.billingPlan === BillingPlan.FREE
);
if (plan === BillingPlan.FREE && hasFreeOrganizations) {
plan = BillingPlan.PRO;
}
return {
plan,
coupon,
hasFreeOrganizations,
name: url.searchParams.get('name') ?? ''
};
};
function getPlanFromUrl(url: URL): BillingPlan | null {
if (url.searchParams.has('plan')) {
const plan = url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
return plan as BillingPlan;
}
}
return BillingPlan.FREE;
}
async function getCoupon(url: URL): Promise<Coupon | null> {
if (url.searchParams.has('code')) {
const coupon = url.searchParams.get('code');
try {
return sdk.forConsole.billing.getCoupon(coupon);
} catch (e) {
return null;
}
}
return null;
}
@@ -65,27 +65,24 @@
rel="noopener noreferrer">view our pricing guide.</Link.Anchor>
</Alert.Inline>
{:else}
<FormList>
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
Budget cap limits do not include the base amount of your plan. <button
class="link"
type="button"
on:click={() => ($showUsageRatesModal = true)}
>Learn more about usage rates.
</button>
</svelte:fragment>
</InputSwitch>
{#if capActive}
<InputNumber
required
placeholder="Add budget cap"
id="cap"
autofocus
label="Budget cap (USD)"
bind:value={budget} />
{/if}
</FormList>
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
Budget cap limits do not include the base amount of your plan. <button
class="link"
type="button"
on:click={() => ($showUsageRatesModal = true)}
>Learn more about usage rates.
</button>
</svelte:fragment>
</InputSwitch>
{#if capActive}
<InputNumber
required
placeholder="Add budget cap"
id="cap"
label="Budget cap (USD)"
bind:value={budget} />
{/if}
{/if}
</svelte:fragment>
@@ -107,11 +107,7 @@
<Link.Button on:click={toggle}>Details</Link.Button>
<svelte:fragment slot="tooltip">
The scheduled payment has failed.
<Link.Button
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}
<Link.Button on:click={() => retryPayment(invoice)}
>Try again
</Link.Button>
</svelte:fragment>
@@ -3,7 +3,6 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert } from '$lib/components';
import {
EstimatedTotalBox,
PlanComparisonBox,
@@ -12,46 +11,29 @@
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import PlanSelection from '$lib/components/billing/planSelection.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
import {
Button,
Form,
FormList,
InputSelect,
InputTags,
InputTextarea,
Label
} from '$lib/elements/forms';
import { Button, Form, InputSelect, InputTags, InputTextarea } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers.js';
import {
WizardSecondaryContainer,
WizardSecondaryContent,
WizardSecondaryFooter
} from '$lib/layout';
import { Wizard } from '$lib/layout';
import { type Coupon, type PaymentList } from '$lib/sdk/billing';
import { plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { plansInfo, tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import {
currentPlan,
organization,
organizationList,
type Organization
} from '$lib/stores/organization';
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 { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Icon } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import { Alert, Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
export let data;
let selectedPlan: BillingPlan = data.plan as BillingPlan;
let selectedCoupon: Partial<Coupon> | null = data.coupon;
$: anyOrgFree = $organizationList.teams?.find(
(org) => (org as Organization)?.billingPlan === BillingPlan.FREE
);
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
let previousPage: string = base;
let showExitModal = false;
afterNavigate(({ from }) => {
@@ -61,7 +43,6 @@
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let billingPlan: Tier = $organization.billingPlan;
let paymentMethodId: string;
let collaborators: string[] =
data?.members?.memberships
@@ -69,53 +50,18 @@
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 selfService: boolean;
onMount(async () => {
if ($page.url.searchParams.has('code')) {
const coupon = $page.url.searchParams.get('code');
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;
}
}
if ($organization?.billingPlan === BillingPlan.SCALE) {
billingPlan = BillingPlan.SCALE;
} else {
billingPlan = BillingPlan.PRO;
}
selfService = $currentPlan?.selfService ?? true;
});
async function loadPaymentMethods() {
methods = await sdk.forConsole.billing.listPaymentMethods();
paymentMethodId =
$organization?.paymentMethodId ??
data.organization.paymentMethodId ??
methods.paymentMethods.find((method) => !!method?.last4)?.$id ??
null;
}
@@ -131,8 +77,8 @@
async function downgrade() {
try {
await sdk.forConsole.billing.updatePlan(
$organization.$id,
billingPlan,
data.organization.$id,
selectedPlan,
paymentMethodId,
null
);
@@ -143,14 +89,14 @@
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: tierToPlan($organization.billingPlan).name,
to: tierToPlan(billingPlan).name,
email: $user.email,
from: tierToPlan(data.organization.billingPlan).name,
to: tierToPlan(selectedPlan).name,
email: data.account.email,
reason: feedbackDowngradeOptions.find(
(option) => option.value === feedbackDowngradeReason
)?.label,
orgId: $organization.$id,
userId: $user.$id,
orgId: data.organization.$id,
userId: data.account.$id,
message: feedbackMessage ?? ''
})
});
@@ -161,12 +107,12 @@
isHtml: true,
message: `
<b>${$organization.name}</b> will change to ${
tierToPlan(billingPlan).name
tierToPlan(selectedPlan).name
} plan at the end of the current billing cycle.`
});
trackEvent(Submit.OrganizationDowngrade, {
plan: tierToPlan(billingPlan)?.name
plan: tierToPlan(selectedPlan)?.name
});
} catch (e) {
addNotification({
@@ -180,15 +126,15 @@
async function upgrade() {
try {
const org = await sdk.forConsole.billing.updatePlan(
$organization.$id,
billingPlan,
data.organization.$id,
selectedPlan,
paymentMethodId,
null
);
//Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
@@ -230,7 +176,7 @@
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(billingPlan)?.name
plan: tierToPlan(selectedPlan)?.name
});
} catch (e) {
addNotification({
@@ -241,124 +187,130 @@
}
}
$: isUpgrade = billingPlan > $organization.billingPlan;
$: isDowngrade = billingPlan < $organization.billingPlan;
$: if (billingPlan !== BillingPlan.FREE) {
$: isUpgrade = data.plan > data.organization.billingPlan;
$: isDowngrade = data.plan < data.organization.billingPlan;
$: if (selectedPlan !== BillingPlan.FREE) {
loadPaymentMethods();
}
$: isButtonDisabled = $organization.billingPlan === billingPlan;
$: isButtonDisabled = $organization.billingPlan === selectedPlan;
</script>
<svelte:head>
<title>Change plan - Appwrite</title>
</svelte:head>
<WizardSecondaryContainer bind:showExitModal href={previousPage} confirmExit>
<svelte:fragment slot="title">Change plan</svelte:fragment>
<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>pricing page</Button>.
</p>
{#if !selfService}
<Alert class="u-position-relative u-margin-block-start-16" type="info"
>Your contract is not eligible for manual changes. Please reach out to schedule
a call or setup a dialog.</Alert>
{/if}
<PlanSelection
bind:billingPlan
bind:selfService
anyOrgFree={!!anyOrgFree}
class="u-margin-block-16" />
{#if isDowngrade}
{#if billingPlan === BillingPlan.FREE}
<PlanExcess
tier={BillingPlan.FREE}
class="u-margin-block-start-24"
members={data?.members?.total ?? 0} />
{:else if billingPlan === BillingPlan.PRO && $organization.billingPlan === BillingPlan.SCALE && collaborators?.length > 0}
{@const extraMembers = collaborators?.length ?? 0}
<Alert type="error" class="u-margin-block-start-24">
<svelte:fragment slot="title">
Your monthly payments will be adjusted for the Pro plan
</svelte:fragment>
After switching plans,
<b
>you will be charged {formatCurrency(
extraMembers *
($plansInfo?.get(billingPlan)?.addons?.member?.price ?? 0)
)} monthly for {extraMembers} team members.</b> This will be reflected in
your next invoice.
</Alert>
<Wizard
title="Change plan"
href={`${base}/organization-${$page.params.organization}`}
bind:showExitModal
confirmExit>
<Form bind:this={formComponent} onSubmit={handleSubmit} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Fieldset legend="Select plan">
<Typography.Text>
For more details on our plans, visit our
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
</Typography.Text>
{#if !data.selfService}
<Alert.Inline status="info">
Your contract is not eligible for manual changes. Please reach out to
schedule a call or setup a dialog.
</Alert.Inline>
{/if}
{/if}
<PlanSelection
bind:billingPlan={selectedPlan}
selfService={data.selfService}
anyOrgFree={!!anyOrgFree} />
{#if isDowngrade}
{#if selectedPlan === BillingPlan.FREE}
<PlanExcess
tier={BillingPlan.FREE}
class="u-margin-block-start-24"
members={data?.members?.total ?? 0} />
{:else if selectedPlan === BillingPlan.PRO && data.organization.billingPlan === BillingPlan.SCALE && collaborators?.length > 0}
{@const extraMembers = collaborators?.length ?? 0}
{@const price = formatCurrency(
extraMembers *
($plansInfo?.get(selectedPlan)?.addons?.member?.price ?? 0)
)}
<Alert.Inline status="error">
<svelte:fragment slot="title">
Your monthly payments will be adjusted for the Pro plan
</svelte:fragment>
After switching plans,
<b
>you will be charged {price} monthly for {extraMembers} team members.</b>
This will be reflected in your next invoice.
</Alert.Inline>
{/if}
{/if}
</Fieldset>
<!-- Show email input if upgrading from free plan -->
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan === BillingPlan.FREE}
<FormList>
{#if selectedPlan !== BillingPlan.FREE && data.organization.billingPlan === BillingPlan.FREE}
<Fieldset legend="Invite members">
<InputTags
bind:tags={collaborators}
label="Invite members by email"
popover={Default}
placeholder="Enter email address(es)"
validityRegex={emailRegex}
validityMessage="Invalid email address"
id="members" />
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId />
{#if !couponData?.code}
<Button
text
class="u-margin-block-start-16 align-left"
on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</FormList>
</Fieldset>
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId />
{#if !selectedCoupon?.code}
<Button text on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
{/if}
{#if isDowngrade && billingPlan === BillingPlan.FREE}
<FormList>
<InputSelect
id="reason"
label="Reason for plan change"
placeholder="Select one"
required
options={feedbackDowngradeOptions}
bind:value={feedbackDowngradeReason} />
<InputTextarea
id="comment"
required
label="If you need to elaborate, please do so here"
placeholder="Enter feedback"
bind:value={feedbackMessage} />
</FormList>
{#if isDowngrade && selectedPlan === BillingPlan.FREE}
<Fieldset legend="Feedback">
<Layout.Stack gap="xl">
<InputSelect
id="reason"
label="Reason for plan change"
placeholder="Select one"
required
options={feedbackDowngradeOptions}
bind:value={feedbackDowngradeReason} />
<InputTextarea
id="comment"
required
label="If you need to elaborate, please do so here"
placeholder="Enter feedback"
bind:value={feedbackMessage} />
</Layout.Stack>
</Fieldset>
{/if}
</Form>
<svelte:fragment slot="aside">
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan && $organization.billingPlan !== BillingPlan.CUSTOM}
<EstimatedTotalBox
{billingPlan}
{collaborators}
bind:couponData
bind:billingBudget
{isDowngrade} />
{:else if $organization.billingPlan !== BillingPlan.CUSTOM}
<PlanComparisonBox downgrade={isDowngrade} />
{/if}
</svelte:fragment>
</WizardSecondaryContent>
<WizardSecondaryFooter>
</Layout.Stack>
</Form>
<svelte:fragment slot="aside">
{#if selectedPlan !== BillingPlan.FREE && data.organization.billingPlan !== selectedPlan && data.organization.billingPlan !== BillingPlan.CUSTOM}
<EstimatedTotalBox
{collaborators}
{isDowngrade}
plans={data.plansInfo}
billingPlan={selectedPlan}
bind:couponData={selectedCoupon}
bind:billingBudget />
{:else if data.organization.billingPlan !== BillingPlan.CUSTOM}
<PlanComparisonBox downgrade={isDowngrade} />
{/if}
</svelte:fragment>
<svelte:fragment slot="footer">
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting || isButtonDisabled || !selfService}>
disabled={$isSubmitting || isButtonDisabled || !data.selfService}>
Change plan
</Button>
</WizardSecondaryFooter>
</WizardSecondaryContainer>
</svelte:fragment>
</Wizard>
<ValidateCreditModal bind:show={showCreditModal} bind:couponData />
<ValidateCreditModal bind:show={showCreditModal} bind:couponData={selectedCoupon} />
@@ -1,11 +1,48 @@
import { Dependencies } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
import type { Coupon } from '$lib/sdk/billing';
export const load: PageLoad = async ({ depends, parent }) => {
const { members } = await parent();
export const load: PageLoad = async ({ depends, parent, url }) => {
const { members, organization, currentPlan } = await parent();
depends(Dependencies.ORGANIZATION);
const coupon = await getCoupon(url);
let plan = getPlanFromUrl(url);
if (organization?.billingPlan === BillingPlan.SCALE) {
plan = BillingPlan.SCALE;
} else {
plan = BillingPlan.PRO;
}
const selfService = currentPlan?.selfService ?? true;
return {
members
members,
plan,
coupon,
selfService
};
};
function getPlanFromUrl(url: URL): BillingPlan | null {
if (url.searchParams.has('plan')) {
const plan = url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
return plan as BillingPlan;
}
}
}
async function getCoupon(url: URL): Promise<Coupon | null> {
if (url.searchParams.has('code')) {
const coupon = url.searchParams.get('code');
try {
return sdk.forConsole.billing.getCoupon(coupon);
} catch (e) {
return null;
}
}
return null;
}