mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
fix: create org
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user