Merge branch 'main' into open-sheet

This commit is contained in:
Darshan
2025-09-02 12:59:54 +05:30
47 changed files with 2135 additions and 584 deletions
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { DropList, GridItem1, CardContainer } from '$lib/components';
import {
Badge,
Icon,
Typography,
Tag,
Accordion,
ActionMenu,
Popover,
Layout
} from '@appwrite.io/pink-svelte';
import {
IconAndroid,
IconApple,
IconCode,
IconFlutter,
IconReact,
IconUnity,
IconInfo,
IconDotsHorizontal,
IconInboxIn,
IconSwitchHorizontal
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import { Status, type Models } from '@appwrite.io/console';
import type { ComponentType } from 'svelte';
import { BillingPlan } from '$lib/constants';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Modal } from '$lib/components';
import { isSmallViewport } from '$lib/stores/viewport';
import { isCloud } from '$lib/system';
import { regions as regionsStore } from '$lib/stores/organization';
import type { Organization } from '$lib/stores/organization';
import type { Plan } from '$lib/sdk/billing';
// props
interface Props {
projectsToArchive: Models.Project[];
organization: Organization;
currentPlan: Plan;
}
let { projectsToArchive, organization, currentPlan }: Props = $props();
// Track Read-only info droplist per archived project
let readOnlyInfoOpen = $state<Record<string, boolean>>({});
let showUnarchiveModal = $state(false);
let projectToUnarchive = $state<Models.Project | null>(null);
function filterPlatforms(platforms: { name: string; icon: string }[]) {
return platforms.filter(
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
);
}
function getIconForPlatform(platform: string): ComponentType {
switch (platform) {
case 'code':
return IconCode;
case 'flutter':
return IconFlutter;
case 'apple':
return IconApple;
case 'android':
return IconAndroid;
case 'react-native':
return IconReact;
case 'unity':
return IconUnity;
default:
return IconCode;
}
}
// Check if unarchive should be disabled
function isUnarchiveDisabled(): boolean {
if (!organization || !currentPlan) return true;
if (organization.billingPlan === BillingPlan.FREE) {
const currentProjectCount = organization.projects?.length || 0;
const projectLimit = currentPlan.projects || 0;
return currentProjectCount >= projectLimit;
}
return false;
}
function handleMigrateProject(project: Models.Project) {
goto(`${base}/project-${project.region}-${project.$id}/settings/migrations`);
}
// Handle unarchive project action
async function handleUnarchiveProject(project: Models.Project) {
projectToUnarchive = project;
showUnarchiveModal = true;
}
// Confirm unarchive action
async function confirmUnarchive() {
if (!projectToUnarchive) return;
try {
if (!organization) {
addNotification({
type: 'error',
message: 'Organization not found'
});
return;
}
await sdk.forConsole.projects.updateStatus(projectToUnarchive.$id, Status.Active);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
message: `${projectToUnarchive.name} has been unarchived`
});
showUnarchiveModal = false;
projectToUnarchive = null;
} catch (error) {
const msg =
error && typeof error === 'object' && 'message' in error
? String((error as { message: string }).message)
: 'Failed to unarchive project';
addNotification({ type: 'error', message: msg });
}
}
function cancelUnarchive() {
showUnarchiveModal = false;
projectToUnarchive = null;
}
function findRegion(project: Models.Project) {
return $regionsStore.regions.find((region) => region.$id === project.region);
}
import { formatName as formatNameHelper } from '$lib/helpers/string';
function formatName(name: string, limit: number = 19) {
return formatNameHelper(name, limit, $isSmallViewport);
}
</script>
{#if projectsToArchive.length > 0}
<div class="archive-projects-margin-top">
<Accordion title="Archived projects" badge={`${projectsToArchive.length}`}>
<Typography.Text tag="p" size="s">
These projects have been archived and are read-only. You can view and migrate their
data.
</Typography.Text>
<div class="archive-projects-margin">
<CardContainer disableEmpty={true} total={projectsToArchive.length}>
{#each projectsToArchive as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
)}
{@const formatted = formatName(project.name)}
<GridItem1>
<svelte:fragment slot="eyebrow">
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
</svelte:fragment>
<svelte:fragment slot="title">{formatted}</svelte:fragment>
<svelte:fragment slot="status">
<div class="status-container">
<DropList
bind:show={readOnlyInfoOpen[project.$id]}
placement="bottom-start"
noArrow>
<Tag
size="s"
style="white-space: nowrap;"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
readOnlyInfoOpen = {
...readOnlyInfoOpen,
[project.$id]: !readOnlyInfoOpen[project.$id]
};
}}>
<Icon icon={IconInfo} size="s" />
<span>Read only</span>
</Tag>
<svelte:fragment slot="list">
<li
class="drop-list-item u-width-250"
style="padding: var(--space-5, 12px) var(--space-6, 16px)">
<span class="u-block u-mb-8">
Archived projects are read-only. You can view
and migrate their data, but they no longer
accept edits or requests.
</span>
</li>
</svelte:fragment>
</DropList>
<Popover let:toggle padding="none" placement="bottom-end">
<Button
text
icon
size="s"
ariaLabel="more options"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(e);
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Button
leadingIcon={IconInboxIn}
disabled={isUnarchiveDisabled()}
on:click={() => handleUnarchiveProject(project)}
>Unarchive project</ActionMenu.Item.Button>
<ActionMenu.Item.Button
leadingIcon={IconSwitchHorizontal}
on:click={() => handleMigrateProject(project)}
>Migrate project</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</div>
</svelte:fragment>
{#each platforms.slice(0, 2) as platform}
{@const icon = getIconForPlatform(platform.icon)}
<Badge
variant="secondary"
content={platform.name}
style="width: max-content;">
<Icon {icon} size="s" slot="start" />
</Badge>
{/each}
{#if platforms.length > 3}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
style="width: max-content;" />
{/if}
<svelte:fragment slot="icons">
{#if isCloud && $regionsStore?.regions}
{@const region = findRegion(project)}
<Typography.Text>{region?.name}</Typography.Text>
{/if}
</svelte:fragment>
</GridItem1>
{/each}
</CardContainer>
</div>
</Accordion>
</div>
{/if}
<!-- Unarchive Confirmation Modal -->
<Modal bind:show={showUnarchiveModal} title="Unarchive project" size="s">
<p>Are you sure you want to unarchive <strong>{projectToUnarchive?.name}</strong>?</p>
<p>This will move the project back to your active projects list.</p>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
<Button secondary on:click={cancelUnarchive}>Cancel</Button>
<Button on:click={confirmUnarchive}>Unarchive</Button>
</Layout.Stack>
</svelte:fragment>
</Modal>
<style>
.archive-projects-margin-top {
margin-top: 36px;
}
.archive-projects-margin {
margin-top: 16px;
margin-bottom: 36px;
}
.status-container {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -20,9 +20,14 @@
plan. Consider upgrading to increase your resource usage.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
<span class="text">View usage</span>
</Button>
{#if !page.data.currentPlan?.usagePerProject}
<Button
href={`${base}/organization-${$organization.$id}/usage`}
text
fullWidthMobile>
<span class="text">View usage</span>
</Button>
{/if}
<Button
href={$upgradeURL}
on:click={() => {
@@ -3,12 +3,8 @@
import { Click } from '$lib/actions/analytics';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import {
billingProjectsLimitDate,
hideBillingHeaderRoutes,
upgradeURL
} from '$lib/stores/billing';
import { currentPlan } from '$lib/stores/organization';
import { hideBillingHeaderRoutes, upgradeURL } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import SelectProjectCloud from './selectProjectCloud.svelte';
import { toLocaleDate } from '$lib/helpers/date';
@@ -31,8 +27,9 @@
type="warning"
title="Action required: You have more than {$currentPlan.projects} projects.">
<svelte:fragment>
Choose which projects to keep before {toLocaleDate(billingProjectsLimitDate)} or upgrade
to Pro. Projects over the limit will be blocked after this date.
Choose which projects to keep before {toLocaleDate(
$organization.billingNextInvoiceDate
)} or upgrade to Pro. Projects over the limit will be blocked after this date.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button
@@ -6,9 +6,8 @@
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { billingProjectsLimitDate } from '$lib/stores/billing';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { currentPlan } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
let {
showSelectProject = $bindable(false),
@@ -149,9 +148,12 @@
{/if}
{#if selectedProjects.length === $currentPlan?.projects}
{@const difference = projects.length - selectedProjects.length}
{@const messagePrefix =
difference > 1 ? `${difference} projects` : `${difference} project`}
<Alert.Inline
status="warning"
title={`${projects.length - selectedProjects.length} projects will be archived on ${toLocaleDate(billingProjectsLimitDate)}`}>
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
<span>
{@html formatProjectsToArchive()}
will be archived.
@@ -90,6 +90,7 @@
<Typography.Text>Everything in the Free plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited databases, buckets, functions</li>
<li>Unlimited seats</li>
<li>{plan.bandwidth}GB bandwidth</li>
<li>{plan.storage}GB storage</li>
<li>{formatNum(plan.executions)} executions</li>
+10 -4
View File
@@ -1,16 +1,22 @@
<script lang="ts">
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import {
calculateExcess,
plansInfo,
tierToPlan,
getServiceLimit,
type Tier
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import type { Aggregation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import type { AggregationTeam } from '$lib/sdk/billing';
export let tier: Tier;
@@ -22,7 +28,7 @@
executions?: number;
members?: number;
} = null;
let aggregation: Aggregation = null;
let aggregation: AggregationTeam = null;
let showExcess = false;
onMount(async () => {
@@ -64,7 +70,7 @@
{#if excess?.members}
<Table.Row.Base {root}>
<Table.Cell {root}>Organization members</Table.Cell>
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
<Table.Cell {root}>{getServiceLimit('members', tier)} members</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up"></span>
+31 -62
View File
@@ -1,77 +1,46 @@
<script lang="ts">
import { BASE_BILLING_PLANS, BillingPlan } from '$lib/constants';
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo, type Tier, tierFree, tierPro, tierScale } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import { Badge, Layout, Typography } from '@appwrite.io/pink-svelte';
import { LabelCard } from '..';
import type { Plan } from '$lib/sdk/billing';
import { page } from '$app/state';
export let billingPlan: Tier;
export let billingPlan: BillingPlan;
export let isNewOrg = false;
export let selfService = true;
export let anyOrgFree = false;
$: freePlan = $plansInfo.get(BillingPlan.FREE);
$: proPlan = $plansInfo.get(BillingPlan.PRO);
$: scalePlan = $plansInfo.get(BillingPlan.SCALE);
$: isBasePlan = BASE_BILLING_PLANS.includes($currentPlan?.$id);
$: plans = Object.values(page.data.plans.plans) as Plan[];
$: currentPlanInList = plans.some((plan) => plan.$id === $currentPlan?.$id);
</script>
<Layout.Stack>
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={!selfService}
value={BillingPlan.FREE}
title={tierFree.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.FREE && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierFree.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(freePlan?.price ?? 0)}
</Typography.Text>
</LabelCard>
<LabelCard
name="plan"
disabled={!selfService}
bind:group={billingPlan}
value={BillingPlan.PRO}
title={tierPro.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.PRO && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierPro.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(proPlan?.price ?? 0)} per month + usage
</Typography.Text>
</LabelCard>
<LabelCard
name="plan"
bind:group={billingPlan}
value={BillingPlan.SCALE}
title={tierScale.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierScale.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</Typography.Text>
</LabelCard>
{#if $currentPlan && !isBasePlan}
{#each plans as plan}
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={!selfService || (plan.$id === BillingPlan.FREE && anyOrgFree)}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
value={plan.$id}
title={plan.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{plan.desc}
</Typography.Caption>
<Typography.Text>
{@const isZeroPrice = (plan.price ?? 0) <= 0}
{@const price = formatCurrency(plan.price ?? 0)}
{isZeroPrice ? price : `${price} per month + usage`}
</Typography.Text>
</LabelCard>
{/each}
{#if $currentPlan && !currentPlanInList}
<LabelCard
name="plan"
bind:group={billingPlan}
+4 -4
View File
@@ -4,7 +4,7 @@
import { toLocaleDate } from '$lib/helpers/date';
import { type Organization } from '$lib/stores/organization';
import { plansInfo } from '$lib/stores/billing';
import { abbreviateNumber, formatCurrency } from '$lib/helpers/numbers';
import { abbreviateNumber, formatCurrency, isWithinSafeRange } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Table, Typography } from '@appwrite.io/pink-svelte';
@@ -21,9 +21,9 @@
// equal or above means unlimited!
const getCorrectSeatsCountValue = (count: number): string | number => {
// php int max is always larger than js
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
return exceedsSafeLimit ? 'Unlimited' : count || 0;
// Check for Infinity or very large numbers
const isUnlimited = count === Infinity || !isWithinSafeRange(count);
return isUnlimited ? 'Unlimited' : count || 0;
};
function getPlanLimit(key: string): number | false {
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { Layout, Card } from '@appwrite.io/pink-svelte';
export let gap: 'none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' = 'l';
</script>
<Card.Base>
<Layout.Stack {gap}>
<slot />
</Layout.Stack>
</Card.Base>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
export let href: string;
export let href: string = null;
</script>
<Card.Link class="card" {href}>
+1
View File
@@ -84,5 +84,6 @@ export { default as UsageCard } from './usageCard.svelte';
export { default as ViewToggle } from './viewToggle.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
+1 -1
View File
@@ -45,7 +45,7 @@
</script>
<Tooltip maxWidth={tooltipWidth} disabled={!tooltipText || !tooltipShow}>
<div style:cursor={disabled ? 'pointer' : ''}>
<div style:cursor={disabled ? 'pointer' : ''} style:z-index="1">
<Card.Selector
{name}
{src}
@@ -0,0 +1,354 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { getServiceLimit, plansInfo } from '$lib/stores/billing';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Badge, Icon, Layout, Table, Typography, Tooltip } from '@appwrite.io/pink-svelte';
import { IconArrowUp, IconInfo } from '@appwrite.io/pink-icons-svelte';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Modal } from '$lib/components';
import { Alert } from '@appwrite.io/pink-svelte';
import { addNotification } from '$lib/stores/notifications';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { organization, type Organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
// Props
type Props = {
organization: Organization;
projects?: Models.Project[];
members?: Models.Membership[];
storageUsage?: number;
};
const { projects = [], members = [], storageUsage = 0 }: Props = $props();
let showSelectProject = $state(false);
let selectedProjects = $state<string[]>([]);
let error = $state<string | null>(null);
let showSelectionReminder = $state(false);
// Derived state using runes
let freePlanLimits = $derived({
projects: $plansInfo?.get(BillingPlan.FREE)?.projects,
members: getServiceLimit('members', BillingPlan.FREE),
storage: getServiceLimit('storage', BillingPlan.FREE)
});
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
let allowedProjectsToKeep = $derived(freePlanLimits.projects);
let currentUsage = $derived({
projects: projects?.length || 0,
members: members?.length || 0,
storage: storageUsage || 0
});
let storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
let isLimitExceeded = $derived({
projects: currentUsage.projects > freePlanLimits.projects,
members: currentUsage.members > freePlanLimits.members,
storage: storageUsageGB > freePlanLimits.storage
});
let excessUsage = $derived({
projects: Math.max(0, currentUsage.projects),
members: Math.max(0, currentUsage.members - freePlanLimits.members),
storage: Math.max(0, storageUsageGB - freePlanLimits.storage)
});
// projects that would be archived with the current selection
let projectsToArchive = $derived(
projects.filter((project) => !selectedProjects.includes(project.$id))
);
function formatProjectsToArchive(): string {
let result = '';
projectsToArchive.forEach((project, index) => {
const isLast = index === projectsToArchive.length - 1;
const isSecondLast = index === projectsToArchive.length - 2;
result += `${index === 0 ? '' : ' '}${project.name}`;
if (!isLast) {
if (isSecondLast) result += ' and';
else result += ',';
}
});
return result;
}
function formatNumber(num: number): string {
return formatNumberWithCommas(num);
}
function handleManageProjects() {
showSelectProject = true;
showSelectionReminder = false;
trackEvent(Click.OrganizationClickUpgrade, { source: 'usage_limits_manage_projects' });
}
// Expose validation for parent to call before submitting downgrade
export function validateOrAlert(): boolean {
const filteredSelection = selectedProjects.filter((id) =>
projects.some((p) => p.$id === id)
);
const isValid = filteredSelection.length === allowedProjectsToKeep;
showSelectionReminder = !isValid && isLimitExceeded.projects;
return isValid;
}
export function getSelectedProjects(): string[] {
return selectedProjects.filter((id) => projects.some((p) => p.$id === id));
}
function updateSelected() {
error = null;
const filteredSelection = selectedProjects.filter((id) =>
projects.some((p) => p.$id === id)
);
if (filteredSelection.length !== allowedProjectsToKeep) {
error = `You must select exactly ${allowedProjectsToKeep} projects to keep.`;
return;
}
// Keep selection locally; parent flow will apply after plan change
showSelectProject = false;
showSelectionReminder = false;
addNotification({ type: 'success', message: `Projects selected for archiving` });
}
</script>
<Layout.Stack gap="l">
{#if showSelectionReminder}
<Alert.Inline status="warning" title="Choose projects to keep">
The Free plan lets you keep {allowedProjectsToKeep} projects. Select them before continuing.
<Layout.Stack
direction="row"
justifyContent="flex-start"
gap="xs"
style="position: relative; z-index: 10; pointer-events: auto;">
<Button compact on:click={handleManageProjects}>Manage projects</Button>
</Layout.Stack>
</Alert.Inline>
{/if}
<div class="responsive-table">
<Table.Root
columns={[
{ id: 'resource', width: { min: 215 } },
{ id: 'freeLimit', width: { min: 100 } },
{ id: 'excessUsage', width: { min: 120 } },
{ id: 'manage', width: { min: 110 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
<Table.Header.Cell column="freeLimit" {root}>Free limit</Table.Header.Cell>
<Table.Header.Cell column="excessUsage" {root}>
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Typography.Text>Excess usage</Typography.Text>
<Tooltip placement="bottom" portal>
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">Usage beyond the Free plan limits.</span>
</Tooltip>
</Layout.Stack>
</Table.Header.Cell>
<Table.Header.Cell column="manage" {root}></Table.Header.Cell>
</svelte:fragment>
<!-- Projects Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Typography.Text>Projects</Typography.Text>
{#if isLimitExceeded.projects}
<Badge
size="xs"
content="Action required"
variant="secondary"
type="warning" />
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text
>{formatNumber(allowedProjectsToKeep)} projects</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.projects}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{formatNumber(excessUsage.projects)} projects
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{formatNumber(currentUsage.projects)} / {formatNumber(
allowedProjectsToKeep
)}
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}>
{#if isLimitExceeded.projects}
<Layout.Stack direction="row" justifyContent="flex-end">
<Button size="xs" secondary on:click={handleManageProjects}
>Manage projects</Button>
</Layout.Stack>
{/if}
</Table.Cell>
</Table.Row.Base>
<!-- Organization Members Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Typography.Text>Organization members</Typography.Text>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text>{formatNumber(freePlanLimits.members)} member</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.members}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{formatNumber(excessUsage.members)} members
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{formatNumber(currentUsage.members)} / {formatNumber(
freePlanLimits.members
)}
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}></Table.Cell>
</Table.Row.Base>
<!-- Storage Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Typography.Text>Storage</Typography.Text>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text>{freePlanLimits.storage} GB</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.storage}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{excessUsage.storage.toFixed(2)} GB
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}></Table.Cell>
</Table.Row.Base>
</Table.Root>
</div>
</Layout.Stack>
{#if showSelectProject}
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
<svelte:fragment slot="description">
Choose which {freePlanLimits.projects} projects to keep. Projects over the limit will be
blocked after your billing cycle ends on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
</svelte:fragment>
{#if error}
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
{/if}
<div>
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedProjects}
columns={[{ id: 'name' }, { id: 'created' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
</svelte:fragment>
{#each projects as project}
<Table.Row.Base {root} id={project.$id}>
<Table.Cell column="name" {root}
><Typography.Text truncate>{project.name}</Typography.Text></Table.Cell>
<Table.Cell column="created" {root}>
{toLocaleDateTime(project.$createdAt)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
{#if selectedProjects.length === allowedProjectsToKeep}
{@const difference = projects.length - selectedProjects.length}
{@const messagePrefix =
difference > 1 ? `${difference} projects` : `${difference} project`}
<Alert.Inline
status="warning"
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
{formatProjectsToArchive()} will be archived
</Alert.Inline>
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showSelectProject = false)}>Cancel</Button>
<Button submit disabled={selectedProjects.length !== allowedProjectsToKeep}
>Save</Button>
</svelte:fragment>
</Modal>
{/if}
<style>
/* Responsive table container */
.responsive-table {
overflow-x: auto;
width: 100%;
min-width: 0;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
position: relative;
z-index: 2;
}
/* Small viewport optimizations */
@media (max-width: 640px) {
.responsive-table {
margin-inline: -1rem;
padding-inline: 1rem;
z-index: 5;
}
.responsive-table :global(td),
.responsive-table :global(th) {
padding: 0.5rem 0.25rem;
font-size: 0.875rem;
}
.responsive-table :global(td:nth-child(1) .badge) {
white-space: nowrap;
flex-shrink: 0;
}
.responsive-table :global(td:nth-child(1) .layout-stack) {
flex-wrap: nowrap;
gap: 6px !important;
}
.responsive-table :global(th:nth-child(4)),
.responsive-table :global(td:nth-child(4)) {
width: 96px;
min-width: 96px;
}
}
</style>
@@ -66,6 +66,7 @@
flex-direction: row;
gap: 2px;
margin-top: 1rem;
overflow: hidden;
}
&__content {
+1 -16
View File
@@ -18,20 +18,6 @@
let error: string;
function coerceToNumber(event: CustomEvent) {
const raw = event.detail ?? '';
if (raw === '') {
value = nullable ? null : (undefined as unknown as number);
return;
}
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
value = parsed;
}
}
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
@@ -74,8 +60,7 @@
autofocus={autofocus || undefined}
helper={error || helper}
state={error ? 'error' : 'default'}
on:invalid={handleInvalid}
on:change={coerceToNumber}>
on:invalid={handleInvalid}>
<svelte:fragment slot="info">
<slot name="info" slot="info" />
</svelte:fragment>
+2 -3
View File
@@ -5,6 +5,7 @@ import type { Organization } from './stores/organization';
// Parse feature flags from env as a string array (exact match only)
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
// @ts-expect-error: unused method!
function isFlagEnabled(name: string) {
// loose generic to allow safe access while retaining type safety
return <T extends { account?: Account; organization?: Organization }>(data: T) => {
@@ -18,6 +19,4 @@ function isFlagEnabled(name: string) {
};
}
export const flags = {
showSites: isFlagEnabled('sites')
};
export const flags = {};
+13
View File
@@ -49,6 +49,19 @@ export function formatNum(number: number): string {
return formatter.format(number);
}
/**
* Format a string with optional mobile-aware truncation.
*/
export function formatName(
name: string,
limit: number = 19,
isSmallViewport: boolean = false
): string {
const mobileLimit = 16;
const actualLimit = isSmallViewport ? mobileLimit : limit;
return name ? (name.length > actualLimit ? `${name.slice(0, actualLimit)}...` : name) : '-';
}
/**
* Returns a regex to check hostname validity. Supports wildcards too!
*/
-28
View File
@@ -1,28 +0,0 @@
import { sdk } from '$lib/stores/sdk';
import { type Account } from '$lib/stores/user';
export const joinWaitlistSites = (user: Account) => {
const prefs = user.prefs;
const newPrefs = {
...prefs,
joinWaitlistSites: true
};
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
if (sessionStorage) {
sessionStorage.setItem('joinWaitlistSites', 'true');
}
};
export const isOnWaitlistSites = (user: Account): boolean => {
const prefs = user.prefs;
const joinedInPrefs = 'joinWaitlistSites' in prefs;
let joinedInSession = false;
if (sessionStorage) {
joinedInSession = sessionStorage.getItem('joinWaitlistSites') === 'true';
}
return joinedInSession || joinedInPrefs;
};
+38 -18
View File
@@ -4,13 +4,15 @@
import { CustomId } from '$lib/components/index.js';
import { getFlagUrl } from '$lib/helpers/flag';
import { isCloud } from '$lib/system.js';
import { currentPlan } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
import { Button } from '$lib/elements/forms';
import { base } from '$app/paths';
import { page } from '$app/state';
import type { Models } from '@appwrite.io/console';
import { filterRegions } from '$lib/helpers/regions';
import type { Snippet } from 'svelte';
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
let {
projectName = $bindable(''),
@@ -18,6 +20,7 @@
regions = [],
region = $bindable(''),
showTitle = true,
billingPlan = undefined,
projects = undefined,
submit
}: {
@@ -26,14 +29,21 @@
regions: Array<Models.ConsoleRegion>;
region: string;
showTitle: boolean;
billingPlan?: BillingPlan;
projects?: number;
submit?: Snippet;
} = $props();
let showCustomId = $state(false);
let isProPlan = $derived((billingPlan ?? $organization?.billingPlan) === BillingPlan.PRO);
let projectsLimited = $derived(
$currentPlan?.projects > 0 && projects && projects >= $currentPlan?.projects
);
let isAddonProject = $derived(
$currentPlan?.addons?.projects?.supported &&
projects &&
projects >= $currentPlan?.addons?.projects?.planIncluded
);
</script>
<svelte:head>
@@ -46,26 +56,12 @@
{#if showTitle}
<Typography.Title size="l">Create your project</Typography.Title>
{/if}
{#if projectsLimited}
<Alert.Inline
status="warning"
title={`You've reached your limit of ${$currentPlan?.projects || 2} projects`}>
Extra projects are available on paid plans for an additional fee
<svelte:fragment slot="actions">
<Button
compact
size="s"
href={`${base}/organization-${page.params.organization}/billing`}
external
text>Upgrade</Button>
</svelte:fragment>
</Alert.Inline>
{/if}
<Layout.Stack direction="column" gap="xxl">
<Layout.Stack direction="column" gap="xxl">
<Layout.Stack direction="column" gap="s">
<Input.Text
disabled={projectsLimited}
disabled={!isProPlan && projectsLimited}
label="Name"
placeholder="Project name"
required
@@ -82,10 +78,11 @@
{/if}
<CustomId bind:show={showCustomId} name="Project" isProject bind:id />
</Layout.Stack>
{#if isCloud && regions.length > 0}
<Layout.Stack gap="xs">
<Input.Select
disabled={projectsLimited}
disabled={!isProPlan && projectsLimited}
required
bind:value={region}
placeholder="Select a region"
@@ -94,6 +91,29 @@
<Typography.Text>Region cannot be changed after creation</Typography.Text>
</Layout.Stack>
{/if}
{#if isAddonProject}
<Alert.Inline
status="info"
title="Expand for {formatCurrency(
$currentPlan?.addons?.projects?.price || 15
)}/project per month">
Each added project comes with its own dedicated pool of resources.
</Alert.Inline>
{/if}
{#if projectsLimited}
<Alert.Inline
status="warning"
title={`You've reached your limit of ${$currentPlan?.projects} projects`}>
Extra projects are available on paid plans for an additional fee
<svelte:fragment slot="actions">
<Button
compact
size="s"
href={`${base}/organization-${page.params.organization}/billing`}
external>Upgrade</Button>
</svelte:fragment>
</Alert.Inline>
{/if}
</Layout.Stack>
</Layout.Stack>
{@render submit?.()}
+101 -2
View File
@@ -151,6 +151,79 @@ export type CreditList = {
total: number;
};
export type AggregationTeam = {
$id: string;
/**
* Aggregation creation time in ISO 8601 format.
*/
$createdAt: string;
/**
* Aggregation update date in ISO 8601 format.
*/
$updatedAt: string;
/**
* Beginning date of the invoice.
*/
from: string;
/**
* End date of the invoice.
*/
to: string;
/**
* Total amount of the invoice.
*/
amount: number;
additionalMembers: number;
/**
* Price for additional members
*/
additionalMemberAmount: number;
/**
* Total storage usage.
*/
usageStorage: number;
/**
* Total active users for the billing period.
*/
usageUsers: number;
/**
* Total number of executions for the billing period.
*/
usageExecutions: number;
/**
* Total bandwidth usage for the billing period.
*/
usageBandwidth: number;
/**
* Total realtime usage for the billing period.
*/
usageRealtime: number;
/**
* Usage logs for the billing period.
*/
resources: InvoiceUsage[];
/**
* Aggregation billing plan
*/
plan: string;
breakdown: AggregationBreakdown[];
};
export type AggregationBreakdown = {
$id: string;
name: string;
amount: number;
region: string;
resources: InvoiceUsage[];
};
export type InvoiceUsage = {
resourceId: string;
value: number;
amount: number;
};
export type AvailableCredit = {
available: number;
};
@@ -299,6 +372,7 @@ export type PlanAddon = {
limit: number;
value: number;
type: string;
planIncluded: number;
};
export type Plan = {
@@ -316,10 +390,13 @@ export type Plan = {
projects: number;
databases: number;
databasesAllowEncrypt: boolean;
databasesReads: number;
databasesWrites: number;
buckets: number;
fileSize: number;
functions: number;
executions: number;
GBHours: number;
realtime: number;
logs: number;
authPhone: number;
@@ -330,9 +407,14 @@ export type Plan = {
realtime: AdditionalResource;
storage: AdditionalResource;
users: AdditionalResource;
databasesReads: AdditionalResource;
databasesWrites: AdditionalResource;
GBHours: AdditionalResource;
imageTransformations: AdditionalResource;
};
addons: {
seats: PlanAddon;
projects: PlanAddon;
};
trialDays: number;
budgetCapEnabled: boolean;
@@ -348,6 +430,7 @@ export type Plan = {
supportsOrganizationRoles: boolean;
buildSize: number; // in MB
deploymentSize: number; // in MB
usagePerProject: boolean;
};
export type PlanList = {
@@ -355,7 +438,7 @@ export type PlanList = {
total: number;
};
export type PlansMap = Map<Tier, Plan>;
export type PlansMap = Map<string, Plan>;
export type Roles = {
scopes: string[];
@@ -492,6 +575,22 @@ export class Billing {
});
}
async listPlans(queries: string[] = []): Promise<PlanList> {
const path = `/console/plans`;
const uri = new URL(this.client.config.endpoint + path);
const params = {
queries
};
return await this.client.call(
'get',
uri,
{
'content-type': 'application/json'
},
params
);
}
async getPlan(planId: string): Promise<Plan> {
const path = `/console/plans/${planId}`;
const uri = new URL(this.client.config.endpoint + path);
@@ -835,7 +934,7 @@ export class Billing {
);
}
async getAggregation(organizationId: string, aggregationId: string): Promise<Aggregation> {
async getAggregation(organizationId: string, aggregationId: string): Promise<AggregationTeam> {
const path = `/organizations/${organizationId}/aggregations/${aggregationId}`;
const params = {
organizationId,
+23 -14
View File
@@ -14,7 +14,7 @@ import { cachedStore } from '$lib/helpers/cache';
import { type Size, sizeToBytes } from '$lib/helpers/sizeConvertion';
import type {
AddressesList,
Aggregation,
AggregationTeam,
Invoice,
InvoiceList,
PaymentList,
@@ -69,7 +69,6 @@ export const roles = [
export const teamStatusReadonly = 'readonly';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const billingProjectsLimitDate = '2025-09-01';
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
@@ -162,8 +161,13 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
// the correct info for members/seats, resides in `addons`.
// plan > addons > seats/others
if (serviceId === 'members') {
// some don't include `limit`, so we fallback!
return plan?.['addons']['seats']['limit'] ?? 1;
// pro and scale plans have unlimited seats (per-project NEW pricing model)
const currentTier = tier ?? get(organization)?.billingPlan;
if (currentTier === BillingPlan.PRO || currentTier === BillingPlan.SCALE) {
return Infinity; // unlimited seats for Pro and Scale plans
}
// Free plan still has 1 member limit
return (plan?.['addons']['seats'] || [])['limit'] ?? 1;
}
return plan?.[serviceId] ?? 0;
@@ -235,6 +239,7 @@ export const tierEnterprise: TierData = {
};
export const showUsageRatesModal = writable<boolean>(false);
export const useNewPricingModal = derived(currentPlan, ($plan) => $plan?.usagePerProject === true);
export function checkForUsageFees(plan: Tier, id: PlanServices) {
if (plan === BillingPlan.PRO || plan === BillingPlan.SCALE) {
@@ -253,11 +258,19 @@ export function checkForUsageFees(plan: Tier, id: PlanServices) {
}
export function checkForProjectLimitation(id: PlanServices) {
// Members are no longer limited on Pro and Scale plans (unlimited seats)
if (id === 'members') {
const currentTier = get(organization)?.billingPlan;
if (currentTier === BillingPlan.PRO || currentTier === BillingPlan.SCALE) {
return false; // No project limitation for members on Pro/Scale plans
}
}
switch (id) {
case 'databases':
case 'functions':
case 'buckets':
case 'members':
case 'members': // Only applies to Free plan now
case 'platforms':
case 'webhooks':
case 'teams':
@@ -325,7 +338,8 @@ export async function checkForProjectsLimit(org: Organization, orgProjectCount?:
if (!plan) return;
if (plan.$id !== BillingPlan.FREE) return;
if (org.projects?.length > 0) return;
if (!org.projects) return;
if (org.projects.length > 0) return;
const projectCount = orgProjectCount;
if (projectCount === undefined) return;
@@ -383,13 +397,8 @@ export async function checkForUsageLimit(org: Organization) {
];
const members = org.total;
const plan = get(currentPlan);
const membersOverflow =
// `plan` can be null on `onboarding/create-organization` route.
// nested null checks needed: GitHub Education plan have empty addons.
members > plan?.addons?.seats?.limit
? members - (plan?.addons?.seats?.limit || members)
: 0;
const memberLimit = getServiceLimit('members');
const membersOverflow = memberLimit === Infinity ? 0 : Math.max(0, members - memberLimit);
if (resources.some((r) => r.value >= 100) || membersOverflow > 0) {
readOnly.set(true);
@@ -609,7 +618,7 @@ export const billingURL = derived(
export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account'];
export function calculateExcess(addon: Aggregation, plan: Plan) {
export function calculateExcess(addon: AggregationTeam, plan: Plan) {
return {
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
+19 -3
View File
@@ -2,10 +2,26 @@ import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ parent }) => {
export const load: LayoutLoad = async ({ parent, url }) => {
const { account } = await parent();
if (!account) {
redirect(303, base);
const projectId = url.searchParams.get('projectId');
const repositoryId = url.searchParams.get('repositoryId');
const installationId = url.searchParams.get('installationId');
const providerPullRequestId = url.searchParams.get('providerPullRequestId');
if (!installationId || !repositoryId || !providerPullRequestId) {
redirect(303, `${base}`);
}
if (!account) {
redirect(303, `${base}`);
}
return {
projectId,
repositoryId,
installationId,
providerPullRequestId
};
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { page } from '$app/state';
import { app } from '$lib/stores/app';
import AppwriteLogoDark from '$lib/images/appwrite-logo-dark.svg';
import AppwriteLogoLight from '$lib/images/appwrite-logo-light.svg';
@@ -7,6 +6,8 @@
import { onMount } from 'svelte';
import { getApiEndpoint } from '$lib/stores/sdk';
export let data;
const endpoint = getApiEndpoint();
const client = new Client();
const vcs = new Vcs(client);
@@ -15,17 +16,16 @@
let repositoryId: string;
let providerPullRequestId: string;
let loading = false;
let error = '';
let success = '';
let loading = false;
onMount(async () => {
const projectId = page.url.searchParams.get('projectId');
client.setEndpoint(endpoint).setProject(projectId).setMode('admin');
repositoryId = data.repositoryId;
installationId = data.installationId;
providerPullRequestId = data.providerPullRequestId;
installationId = page.url.searchParams.get('installationId');
repositoryId = page.url.searchParams.get('repositoryId');
providerPullRequestId = page.url.searchParams.get('providerPullRequestId') + '';
client.setEndpoint(endpoint).setProject(data.projectId).setMode('admin');
});
async function approveDeployment() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';
import { Dependencies } from '$lib/constants';
import type { Tier } from '$lib/stores/billing';
import type { Plan, PlanList } from '$lib/sdk/billing';
import { Query } from '@appwrite.io/console';
@@ -7,10 +7,10 @@ import type { Organization } from '$lib/stores/organization';
export const load: PageLoad = async ({ url, parent, depends }) => {
const { organizations } = await parent();
depends(Dependencies.ORGANIZATIONS);
const [coupon, paymentMethods] = await Promise.all([
const [coupon, paymentMethods, plans] = await Promise.all([
getCoupon(url),
sdk.forConsole.billing.listPaymentMethods()
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listPlans()
]);
let plan = getPlanFromUrl(url);
const hasFreeOrganizations = organizations.teams?.some(
@@ -24,6 +24,7 @@ export const load: PageLoad = async ({ url, parent, depends }) => {
return {
plan,
coupon,
plans,
hasFreeOrganizations,
paymentMethods,
name: url.searchParams.get('name') ?? ''
@@ -7,6 +7,7 @@
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
import { page } from '$app/state';
import { registerCommands } from '$lib/commandCenter';
import { formatName as formatNameHelper } from '$lib/helpers/string';
import {
CardContainer,
Empty,
@@ -17,7 +18,7 @@
} from '$lib/components';
import { trackEvent, Click } from '$lib/actions/analytics';
import { type Models } from '@appwrite.io/console';
import { billingProjectsLimitDate, readOnly, upgradeURL } from '$lib/stores/billing';
import { readOnly, upgradeURL } from '$lib/stores/billing';
import { onMount, type ComponentType } from 'svelte';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
@@ -34,8 +35,9 @@
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { currentPlan, regions as regionsStore } from '$lib/stores/organization';
import { currentPlan, organization, regions as regionsStore } from '$lib/stores/organization';
import SelectProjectCloud from '$lib/components/billing/alerts/selectProjectCloud.svelte';
import ArchiveProject from '$lib/components/archiveProject.svelte';
import { toLocaleDate } from '$lib/helpers/date';
export let data;
@@ -99,24 +101,16 @@
function isSetToArchive(project: Models.Project): boolean {
if (!isCloud) return false;
if (data.organization.projects?.length === 0) return false;
if (!project || !project.$id) return false;
return !data.organization.projects?.includes(project.$id);
return project.status !== 'active';
}
function formatName(name: string, limit: number = 19) {
const mobileLimit = 16;
const actualLimit = $isSmallViewport ? mobileLimit : limit;
return name ? (name.length > actualLimit ? `${name.slice(0, actualLimit)}...` : name) : '-';
}
$: projectsToArchive = data.projects.projects.filter((project) => project.status !== 'active');
$: activeProjects = data.projects.projects.filter((project) => project.status === 'active');
function clearSearch() {
searchQuery?.clearInput();
}
$: projectsToArchive = data.projects.projects.filter(
(project) => !data.organization.projects?.includes(project.$id)
);
</script>
<SelectProjectCloud
@@ -139,24 +133,16 @@
{/if}
</Layout.Stack>
{#if isCloud && $currentPlan?.projects && $currentPlan?.projects > 0 && data.organization.projects.length > 0 && data.projects.total > $currentPlan.projects && $canWriteProjects}
{#if isCloud && $currentPlan?.projects && $currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > $currentPlan.projects)}
{@const difference = projectsToArchive}
{@const messagePrefix =
difference.length > 1 ? `${difference} projects` : `${difference} project`}
<Alert.Inline
title={`${data.projects.total - data.organization.projects.length} projects will be archived on ${toLocaleDate(billingProjectsLimitDate)}`}>
<Typography.Text>
{#each projectsToArchive as project, index}{@const text = `<b>${project.name}</b>`}
{@html text}{index === projectsToArchive.length - 2
? ', and '
: index < projectsToArchive.length - 1
? ', '
: ''}
{/each}
will be archived
</Typography.Text>
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
<Typography.Text>Upgrade your plan to restore archived projects</Typography.Text>
<svelte:fragment slot="actions">
<Button secondary size="s" on:click={() => (showSelectProject = true)}>
Manage projects
</Button>
<Button
compact
size="s"
href={$upgradeURL}
on:click={() => {
@@ -171,18 +157,18 @@
</Alert.Inline>
{/if}
{#if data.projects.total}
{#if activeProjects.length > 0}
<CardContainer
disableEmpty={!$canWriteProjects}
total={data.projects.total}
total={activeProjects.length}
offset={data.offset}
on:click={handleCreateProject}>
{#each data.projects.projects as project}
{#each activeProjects as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
)}
{@const formatted = isSetToArchive(project)
? formatName(project.name)
? formatNameHelper(project.name, isSmallViewport ? 19 : 25)
: project.name}
<GridItem1
href={`${base}/project-${project.region}-${project.$id}/overview/platforms`}>
@@ -259,9 +245,14 @@
name="Projects"
limit={data.limit}
offset={data.offset}
total={data.projects.total} />
</Container>
total={activeProjects.length} />
<!-- Archived Projects Section -->
<ArchiveProject
{projectsToArchive}
organization={data.organization}
currentPlan={$currentPlan} />
</Container>
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
<CreateProjectCloud
@@ -2,12 +2,13 @@
import { Container } from '$lib/layout';
import BudgetCap from './budgetCap.svelte';
import PlanSummary from './planSummary.svelte';
import PlanSummaryOld from './planSummaryOld.svelte';
import BillingAddress from './billingAddress.svelte';
import PaymentMethods from './paymentMethods.svelte';
import AvailableCredit from './availableCredit.svelte';
import PaymentHistory from './paymentHistory.svelte';
import TaxId from './taxId.svelte';
import { failedInvoice, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { failedInvoice, tierToPlan, upgradeURL, useNewPricingModal } from '$lib/stores/billing';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { onMount } from 'svelte';
import { page } from '$app/state';
@@ -17,7 +18,7 @@
import RetryPaymentModal from './retryPaymentModal.svelte';
import { selectedInvoice, showRetryModal } from './store';
import { Button } from '$lib/elements/forms';
import { Alert, Typography } from '@appwrite.io/pink-svelte';
import { Alert } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { base } from '$app/paths';
@@ -127,12 +128,19 @@
until your billing period ends on {toLocaleDate(organization.billingNextInvoiceDate)}.
</Alert.Inline>
{/if}
<Typography.Title>Billing</Typography.Title>
<PlanSummary
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
currentAggregation={data?.billingAggregation}
currentInvoice={data?.billingInvoice} />
{#if $useNewPricingModal}
<PlanSummary
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
nextPlan={data?.nextPlan}
currentAggregation={data?.billingAggregation} />
{:else}
<PlanSummaryOld
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
currentAggregation={data?.billingAggregation}
currentInvoice={data?.billingInvoice} />
{/if}
<PaymentHistory />
<PaymentMethods organization={data?.organization} methods={data?.paymentMethods} />
<BillingAddress
@@ -57,12 +57,18 @@ export const load: PageLoad = async ({ parent, depends }) => {
organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION))
: false;
const [paymentMethods, addressList, billingAddress, availableCredit] = await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
billingAddressPromise,
areCreditsSupported ? sdk.forConsole.billing.getAvailableCredit(organization.$id) : null
]);
const [paymentMethods, addressList, billingAddress, availableCredit, billingPlanDowngrade] =
await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
billingAddressPromise,
areCreditsSupported
? sdk.forConsole.billing.getAvailableCredit(organization.$id)
: null,
organization.billingPlanDowngrade
? sdk.forConsole.billing.getPlan(organization.billingPlanDowngrade)
: null
]);
// make number
const credits = availableCredit ? availableCredit.available : null;
@@ -76,6 +82,7 @@ export const load: PageLoad = async ({ parent, depends }) => {
billingInvoice,
areCreditsSupported,
countryList,
locale
locale,
nextPlan: billingPlanDowngrade
};
};
@@ -36,7 +36,7 @@
}
</script>
<Modal bind:show title="Add credits" onSubmit={redeem} bind:error>
<Modal bind:show title="Add credits" onSubmit={redeem} bind:error size="s">
<svelte:fragment slot="description">
Apply Appwrite credits to your organization.
</svelte:fragment>
@@ -7,7 +7,6 @@
import { wizard } from '$lib/stores/wizard';
import { Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import AddCreditWizard from './addCreditWizard.svelte';
import { Button } from '$lib/elements/forms';
import AddCreditModal from './addCreditModal.svelte';
import { formatCurrency } from '$lib/helpers/numbers';
@@ -33,15 +32,6 @@
const limit = 5;
function handleCredits() {
if ($organization?.paymentMethodId || $organization?.backupPaymentMethodId) {
show = true;
} else {
wizard.start(AddCreditWizard);
reloadOnWizardClose = true;
}
}
async function request() {
if (!$organization?.$id) return;
@@ -116,7 +106,7 @@
content={formatCurrency(creditList.available)} />
</div>
{#if creditList?.total}
<Button secondary on:click={handleCredits}>
<Button secondary on:click={() => (show = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
@@ -176,7 +166,7 @@
</div>
{/if}
{:else}
<Empty target="credits" on:click={handleCredits}>Add credits</Empty>
<Empty target="credits" on:click={() => (show = true)}>Add credits</Empty>
{/if}
{/if}
</svelte:fragment>
@@ -28,7 +28,12 @@
}
</script>
<Modal title="Cancel plan change" onSubmit={cancelDowngrade} bind:show={showCancel} bind:error>
<Modal
title="Cancel plan change"
onSubmit={cancelDowngrade}
bind:show={showCancel}
bind:error
size="s">
<p>
Your organization is set to change to <strong>
{tierToPlan($organization?.billingPlanDowngrade).name}</strong>
@@ -1,180 +1,531 @@
<script lang="ts">
import { base } from '$app/paths';
import { CardGrid } from '$lib/components';
import { EstimatedCard } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, upgradeURL } from '$lib/stores/billing';
import { upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import type { Aggregation, Invoice, Plan } from '$lib/sdk/billing';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import type { AggregationTeam, Plan } from '$lib/sdk/billing';
import { formatCurrency } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import {
Accordion,
Card,
Divider,
Typography,
Expandable as ExpandableTable,
Icon,
Layout,
Tooltip,
Typography
Divider
} from '@appwrite.io/pink-svelte';
import { IconInfo, IconTag } from '@appwrite.io/pink-icons-svelte';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { formatNum } from '$lib/helpers/string';
import { ProgressBar } from '$lib/components';
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import CancelDowngradeModel from './cancelDowngradeModal.svelte';
import { IconTag } from '@appwrite.io/pink-icons-svelte';
export let currentPlan: Plan;
export let currentInvoice: Invoice | undefined = undefined;
export let nextPlan: Plan | null = null;
export let currentAggregation: AggregationTeam | undefined = undefined;
export let availableCredit: number | undefined = undefined;
export let currentAggregation: Aggregation | undefined = undefined;
let showCancel: boolean = false;
const today = new Date();
const isTrial =
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
$plansInfo.get($organization.billingPlan)?.trialDays;
const extraUsage = currentInvoice ? currentInvoice.amount - currentPlan?.price : 0;
// define columns for the expandable table
const columns = [
{ id: 'item', align: 'left' as const, width: '10fr' },
{ id: 'usage', align: 'left' as const, width: '20fr' },
{ id: 'price', align: 'right' as const, width: '0fr' }
];
function formatHumanSize(bytes: number): string {
const size = humanFileSize(bytes || 0);
return `${size.value} ${size.unit}`;
}
function formatBandwidthUsage(currentBytes: number, maxGB?: number): string {
const currentSize = humanFileSize(currentBytes || 0);
if (!maxGB) {
return `${currentSize.value} ${currentSize.unit} / Unlimited`;
}
const maxSize = humanFileSize(maxGB * 1000 * 1000 * 1000);
return `${currentSize.value} ${currentSize.unit} / ${maxSize.value} ${maxSize.unit}`;
}
function truncateForSmall(name: string): string {
if (!name) return name;
return name.length > 12 ? `${name.slice(0, 12)}…` : name;
}
function createProgressData(
currentValue: number,
maxValue: number | string
): Array<{ size: number; color: string; tooltip?: { title: string; label: string } }> {
if (
maxValue === null ||
maxValue === undefined ||
(typeof maxValue === 'number' && maxValue <= 0)
) {
return [];
}
const max = typeof maxValue === 'string' ? parseFloat(maxValue) : maxValue;
if (max <= 0) return [];
const percentage = Math.min((currentValue / max) * 100, 100);
const progressColor = 'var(--bgcolor-neutral-invert)';
return [
{
size: currentValue,
color: progressColor,
tooltip: {
title: `${percentage.toFixed(1)}% used`,
label: `${currentValue.toLocaleString()} of ${max.toLocaleString()}`
}
}
];
}
function createStorageProgressData(
currentBytes: number,
maxGB: number
): Array<{ size: number; color: string; tooltip?: { title: string; label: string } }> {
if (maxGB <= 0) return [];
const maxBytes = maxGB * 1000 * 1000 * 1000;
const percentage = Math.min((currentBytes / maxBytes) * 100, 100);
const progressColor = 'var(--bgcolor-neutral-invert)';
const currentSize = humanFileSize(currentBytes);
return [
{
size: currentBytes,
color: progressColor,
tooltip: {
title: `${percentage.toFixed(0)}% used`,
label: `${currentSize.value} ${currentSize.unit} of ${maxGB} GB`
}
}
];
}
function getProjectsList(currentAggregation) {
return (
currentAggregation?.breakdown?.map((projectData) => ({
projectId: projectData.$id,
name: projectData.name,
region: projectData.region,
amount: projectData.amount,
storage: projectData?.resources?.find((res) => res.resourceId === 'storage'),
executions: projectData?.resources?.find(
(resource) => resource.resourceId === 'executions'
),
gbHours: projectData?.resources?.find(
(resource) => resource.resourceId === 'GBHours'
),
bandwidth: projectData?.resources?.find(
(resource) => resource.resourceId === 'bandwidth'
),
databasesReads: projectData?.resources?.find(
(resource) => resource.resourceId === 'databasesReads'
),
databasesWrites: projectData?.resources?.find(
(resource) => resource.resourceId === 'databasesWrites'
),
users: projectData?.resources?.find((resource) => resource.resourceId === 'users'),
authPhone: projectData?.resources?.find(
(resource) => resource.resourceId === 'authPhone'
)
})) || []
);
}
function getBillingData(currentPlan, currentAggregation, isSmallViewport) {
const projectsList = getProjectsList(currentAggregation);
const base = {
id: 'base-plan',
expandable: false,
cells: {
item: 'Base plan',
usage: '',
price: formatCurrency(
Math.max((nextPlan?.price ?? currentPlan?.price ?? 0) - availableCredit, 0)
)
},
children: []
};
const addons = (currentAggregation?.resources || [])
.filter(
(r) =>
r.amount &&
r.amount > 0 &&
Object.keys(currentPlan?.addons || {}).includes(r.resourceId) &&
currentPlan.addons[r.resourceId]?.price > 0
)
.map((excess) => ({
id: `addon-${excess.resourceId}`,
expandable: false,
cells: {
item:
excess.resourceId === 'seats'
? 'Additional members'
: excess.resourceId === 'projects'
? `Additional Projects (${formatNum(excess.value)})`
: `${excess.resourceId} overage (${formatNum(excess.value)})`,
usage: '',
price: formatCurrency(excess.amount)
},
children: []
}));
const projects = projectsList.map((project) => ({
id: `project-${project.projectId}`,
expandable: true,
cells: {
item: isSmallViewport
? truncateForSmall(project.name)
: project.name || `Project ${project.projectId}`,
usage: '',
price: formatCurrency(project.amount || 0)
},
children: [
{
id: `project-${project.projectId}-bandwidth`,
cells: {
item: 'Bandwidth',
usage: `${formatBandwidthUsage(project.bandwidth.value, currentPlan?.bandwidth)}`,
price: formatCurrency(project.bandwidth.amount || 0)
},
progressData: createStorageProgressData(
project.bandwidth.value || 0,
currentPlan?.bandwidth || 0
),
maxValue: currentPlan?.bandwidth
? currentPlan.bandwidth * 1000 * 1000 * 1000
: 0
},
{
id: `project-${project.projectId}-users`,
cells: {
item: 'Users',
usage: `${formatNum(project.users.value || 0)} / ${currentPlan?.users ? formatNum(currentPlan.users) : 'Unlimited'}`,
price: formatCurrency(project.users.amount || 0)
},
progressData: createProgressData(project.users.value || 0, currentPlan?.users),
maxValue: currentPlan?.users
},
{
id: `project-${project.projectId}-reads`,
cells: {
item: 'Database reads',
usage: `${formatNum(project.databasesReads.value || 0)} / ${currentPlan?.databasesReads ? formatNum(currentPlan.databasesReads) : 'Unlimited'}`,
price: formatCurrency(project.databasesReads.amount || 0)
},
progressData: createProgressData(
project.databasesReads.value || 0,
currentPlan?.databasesReads
),
maxValue: currentPlan?.databasesReads
},
{
id: `project-${project.projectId}-writes`,
cells: {
item: 'Database writes',
usage: `${formatNum(project.databasesWrites.value || 0)} / ${currentPlan?.databasesWrites ? formatNum(currentPlan.databasesWrites) : 'Unlimited'}`,
price: formatCurrency(project.databasesWrites.amount || 0)
},
progressData: createProgressData(
project.databasesWrites.value || 0,
currentPlan?.databasesWrites
),
maxValue: currentPlan?.databasesWrites
},
{
id: `project-${project.projectId}-executions`,
cells: {
item: 'Executions',
usage: `${formatNum(project.executions.value || 0)} / ${currentPlan?.executions ? formatNum(currentPlan.executions) : 'Unlimited'}`,
price: formatCurrency(project.executions.amount || 0)
},
progressData: createProgressData(
project.executions.value || 0,
currentPlan?.executions
),
maxValue: currentPlan?.executions
},
{
id: `project-${project.projectId}-storage`,
cells: {
item: 'Storage',
usage: `${formatHumanSize(project.storage.value || 0)} / ${currentPlan?.storage?.toString() || '0'} GB`,
price: formatCurrency(project.storage.amount || 0)
},
progressData: createStorageProgressData(
project.storage.value || 0,
currentPlan?.storage || 0
),
maxValue: currentPlan?.storage ? currentPlan.storage * 1000 * 1000 * 1000 : 0
},
{
id: `project-${project.projectId}-gb-hours`,
cells: {
item: 'GB-hours',
usage: `${formatNum(project.gbHours.value || 0)} / ${currentPlan?.GBHours ? formatNum(currentPlan.GBHours) : 'Unlimited'}`,
price: formatCurrency(project.gbHours.amount || 0)
},
progressData: currentPlan?.GBHours
? createProgressData(project.gbHours.value || 0, currentPlan.GBHours)
: [],
maxValue: currentPlan?.GBHours ? currentPlan.GBHours : null
},
{
id: `project-${project.projectId}-sms`,
cells: {
item: 'Phone OTP',
usage: `${formatNum(project.authPhone.value || 0)} SMS messages`,
price: formatCurrency(project.authPhone.amount || 0)
}
},
{
id: `project-${project.projectId}-usage-details`,
cells: {
item: `<a href="/console/project-${String(project.region || 'default')}-${project.projectId}/settings/usage" style="text-decoration: underline; color: var(--fgcolor-accent-neutral);">Usage details</a>`,
usage: '',
price: ''
}
}
]
}));
const noProjects = [];
return [base, ...addons, ...projects, ...noProjects];
}
$: billingData = getBillingData(currentPlan, currentAggregation, $isSmallViewport);
$: totalAmount = Math.max(
(currentAggregation?.amount ?? currentPlan?.price ?? 0) - availableCredit,
0
);
$: creditsApplied = Math.min(
currentAggregation?.amount ?? currentPlan?.price ?? 0,
availableCredit
);
</script>
{#if $organization}
<CardGrid>
<svelte:fragment slot="title">Payment estimates</svelte:fragment>
A breakdown of your estimated upcoming payment for the current billing period. Totals displayed
exclude accumulated credits and applicable taxes.
<svelte:fragment slot="aside">
<p class="text u-bold">
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Card.Base variant="secondary" padding="s">
<Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{currentPlan.name} plan
</Typography.Text>
<Typography.Text>
{isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
? formatCurrency(0)
: currentPlan
? formatCurrency(currentPlan?.price)
: ''}
</Typography.Text>
</Layout.Stack>
<EstimatedCard>
<Typography.Title size="s" gap="s">{currentPlan.name} plan</Typography.Title>
{#if currentPlan.budgeting && extraUsage > 0}
<Accordion
hideDivider
title="Add-ons"
badge={(currentAggregation.additionalMembers > 0
? currentInvoice.usage.length + 1
: currentInvoice.usage.length
).toString()}>
<svelte:fragment slot="end">
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</svelte:fragment>
<Layout.Stack gap="xs">
{#if currentAggregation.additionalMembers}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary"
>Additional members</Typography.Text>
<Typography.Text>
{formatCurrency(
currentAggregation.additionalMemberAmount
)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Typography.Text
>{currentAggregation.additionalMembers}</Typography.Text>
</Layout.Stack>
</Layout.Stack>
{#if totalAmount > 0}
<Typography.Text color="--fgcolor-neutral-secondary">
Next payment of <span class="text --fgcolor-neutral-primary u-bold"
>{formatCurrency(totalAmount)}</span>
will occur on
<span class="text --fgcolor-neutral-primary u-bold"
>{toLocaleDate($organization?.billingNextInvoiceDate)}</span
>.
</Typography.Text>
{/if}
<Divider />
<div class="billing-cycle-header">
<Typography.Text color="--fgcolor-neutral-secondary" variant="m-500">
Current billing cycle ({new Date(
$organization?.billingCurrentInvoiceDate
).toLocaleDateString('en', { day: 'numeric', month: 'short' })}-{new Date(
$organization?.billingNextInvoiceDate
).toLocaleDateString('en', { day: 'numeric', month: 'short' })})
</Typography.Text>
<Typography.Text color="--fgcolor-neutral-tertiary" variant="m-400">
Estimate, subject to change based on usage.
</Typography.Text>
</div>
<!-- Billing breakdown table -->
<div class="table-wrapper" class:is-mobile={$isSmallViewport}>
<ExpandableTable.Root {columns} showHeader={false} let:root>
{#each billingData as row}
<ExpandableTable.Row {root} id={row.id} expandable={row.expandable ?? false}>
{#each columns as col}
<ExpandableTable.Cell
{root}
column={col.id}
expandable={row.expandable ?? false}
isOpen={root.isOpen(row.id)}
toggle={() => root.toggle(row.id)}>
{#if col.id === 'item'}
<div class="cell-item-text">
<Typography.Text>
{row.cells?.[col.id] ?? ''}
</Typography.Text>
</div>
{:else}
<Typography.Text>
{row.cells?.[col.id] ?? ''}
</Typography.Text>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
{#if i > 0 || currentAggregation.additionalMembers}
<Divider />
{/if}
</ExpandableTable.Cell>
{/each}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{excess.name}
</Typography.Text>
<Typography.Text>
{formatCurrency(excess.amount)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Tooltip
placement="bottom"
disabled={excess.value <= 1000}>
<svelte:fragment slot="tooltip">
{formatNumberWithCommas(excess.value)}
</svelte:fragment>
<span>{abbreviateNumber(excess.value)}</span>
</Tooltip>
</Layout.Stack>
</Layout.Stack>
{/each}
{/if}
</Layout.Stack>
</Accordion>
{/if}
<svelte:fragment slot="summary">
{#if row.children}
{#each row.children as child (child.id)}
<div
class="child-row"
class:is-tablet={$isTabletViewport && !$isSmallViewport}
style="grid-template-columns: {root.childGridTemplate}; --original-grid-template: {root.childGridTemplate};">
{#each columns as col}
<div
class="child-cell"
class:price={col.id === 'price'}
class:is-mobile={$isSmallViewport}
style="justify-content: {root.alignment(
col.align
)};">
{#if child.cells?.[col.id]?.includes('<a href=')}
{@html child.cells?.[col.id] ?? ''}
{:else if col.id === 'usage'}
<div
class="usage-cell-content"
class:is-mobile={$isSmallViewport}
class:is-tablet={$isTabletViewport &&
!$isSmallViewport}>
<div class="usage-progress-section">
{#if child.progressData && child.progressData.length > 0 && child.maxValue}
<ProgressBar
maxSize={child.maxValue}
data={child.progressData} />
{/if}
</div>
<div class="usage-text-section">
{#if child.cells?.[col.id]?.includes(' / ')}
{@const usageParts = (
child.cells?.[col.id] ?? ''
).split(' / ')}
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-secondary">
{usageParts[0]}
</Typography.Text>
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary">
{' / '}
</Typography.Text>
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary">
{usageParts[1]}
</Typography.Text>
{:else}
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-secondary">
{child.cells?.[col.id] ?? ''}
</Typography.Text>
{/if}
</div>
</div>
{:else}
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-secondary">
{child.cells?.[col.id] ?? ''}
</Typography.Text>
{/if}
</div>
{/each}
</div>
{/each}
{/if}
</svelte:fragment>
</ExpandableTable.Row>
{/each}
{#if availableCredit > 0}
<ExpandableTable.Row {root} id="total-row" expandable={false}>
<ExpandableTable.Cell
{root}
column="item"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Layout.Stack
inline
direction="row"
gap="xxs"
alignItems="center"
alignContent="center">
<Icon icon={IconTag} color="--fgcolor-success" size="s" />
{#if currentPlan.supportsCredits && availableCredit > 0}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" gap="xxs">
<Icon size="s" icon={IconTag} color="--fgcolor-success" />
<Typography.Text color="--fgcolor-neutral-primary"
>Credits to be applied</Typography.Text>
>Credits</Typography.Text>
</Layout.Stack>
<Typography.Text color="--fgcolor-success">
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</ExpandableTable.Cell>
<ExpandableTable.Cell
{root}
column="usage"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
</Typography.Text>
</Layout.Stack>
{/if}
</ExpandableTable.Cell>
<ExpandableTable.Cell
{root}
column="price"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
-{formatCurrency(creditsApplied)}
</Typography.Text>
</ExpandableTable.Cell>
</ExpandableTable.Row>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary" variant="m-500">
<Layout.Stack direction="row" alignItems="center" gap="s">
Current total (USD)
<Tooltip>
<Icon icon={IconInfo} />
<svelte:fragment slot="tooltip">
Estimates are updated daily and may differ from your
final invoice.
</svelte:fragment>
</Tooltip>
</Layout.Stack>
</Typography.Text>
<Typography.Text color="--fgcolor-neutral-primary" variant="m-500">
{formatCurrency(
Math.max(
(currentInvoice?.amount ?? 0) -
Math.min(availableCredit, currentInvoice?.amount ?? 0),
0
)
)}
</Typography.Text>
</Layout.Stack>
{/if}
</Layout.Stack>
</Card.Base>
</svelte:fragment>
<svelte:fragment slot="actions">
<ExpandableTable.Row {root} id="total-row" expandable={false}>
<ExpandableTable.Cell
{root}
column="item"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
Total
</Typography.Text>
</ExpandableTable.Cell>
<ExpandableTable.Cell
{root}
column="usage"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
</Typography.Text>
</ExpandableTable.Cell>
<ExpandableTable.Cell
{root}
column="price"
expandable={false}
isOpen={false}
toggle={() => {}}>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
{formatCurrency(totalAmount)}
</Typography.Text>
</ExpandableTable.Cell>
</ExpandableTable.Row>
</ExpandableTable.Root>
</div>
<!-- Actions -->
<div class="actions-container">
{#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
class="u-flex u-cross-center u-gap-8 u-flex-wrap u-width-full-line u-main-end actions-mobile">
{#if !currentPlan?.usagePerProject}
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
{/if}
<Button
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
@@ -188,7 +539,7 @@
</div>
{:else}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
class="u-flex u-cross-center u-gap-8 u-flex-wrap u-width-full-line u-main-end actions-mobile">
{#if $organization?.billingPlanDowngrade !== null}
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
{:else}
@@ -204,20 +555,24 @@
Change plan
</Button>
{/if}
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
{#if !currentPlan?.usagePerProject}
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
{/if}
</div>
{/if}
</svelte:fragment>
</CardGrid>
</div>
</EstimatedCard>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
--billing-card-border-color: hsl(var(--color-neutral-10));
.billing-cycle-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
:global(.card-only-on-desktop) {
@@ -236,7 +591,159 @@
border-block-start: solid 0.0625rem hsl(var(--p-toggle-border-color));
}
/* indent child rows on tablet */
:global(.child-row.is-tablet) {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
/* prevent wrapping on desktop; truncate on small/tablet */
.cell-item-text {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.theme-dark) .cell-item-text,
:global(.theme-light) .cell-item-text {
display: block;
}
/* Small and tablet: allow truncation to avoid multi-line wrapping */
@media (max-width: 1000px) {
.cell-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.usage-cell-content {
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 1.5rem;
gap: 0.75rem;
width: 100%;
min-height: 2rem;
}
/* tablet tweaks */
:global(.usage-cell-content.is-tablet) {
padding-left: 1rem;
gap: 0.5rem;
}
/* mobile tweaks: compact inline layout with proper spacing */
:global(.usage-cell-content.is-mobile) {
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding-left: 0;
min-height: 2rem;
flex-wrap: nowrap;
}
.usage-text-section {
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 1rem;
flex: 1;
text-align: left;
}
:global(.usage-cell-content.is-mobile .usage-text-section),
:global(.usage-cell-content.is-tablet .usage-text-section) {
padding-left: 0;
}
.usage-progress-section {
display: flex;
align-items: center;
justify-content: flex-start;
width: 264px;
}
:global(.usage-progress-section .progressbar__container) {
margin-top: 0;
width: 264px;
max-width: 264px;
/* Neutral background for unfilled track */
--progressbar-background-color: var(--bgcolor-neutral-tertiary);
}
/* smaller bars for tablet/mobile */
:global(.usage-cell-content.is-tablet .usage-progress-section .progressbar__container) {
width: 200px;
max-width: 200px;
}
:global(.usage-cell-content.is-mobile .usage-progress-section .progressbar__container) {
width: 120px;
max-width: 120px;
}
:global(.usage-cell-content.is-mobile .usage-progress-section) {
width: 120px;
flex-shrink: 0;
}
/* mobile table wrapper for horizontal scroll */
.table-wrapper.is-mobile {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -1rem;
padding: 0 1rem;
}
/* reset mobile overrides - use desktop layout in scrollable container */
.table-wrapper.is-mobile :global(.child-row) {
grid-template-columns: var(--original-grid-template) !important;
min-width: 600px; /* ensure minimum width for proper layout */
}
.table-wrapper.is-mobile :global(.usage-cell-content) {
flex-direction: row !important;
align-items: center !important;
gap: 0.75rem !important;
padding-left: 1rem !important;
min-height: 2rem !important;
}
.table-wrapper.is-mobile :global(.usage-progress-section) {
width: 200px !important;
flex-shrink: 0 !important;
}
.table-wrapper.is-mobile :global(.usage-progress-section .progressbar__container) {
width: 200px !important;
max-width: 200px !important;
}
@media (max-width: 768px) {
.actions-mobile {
justify-content: flex-start !important;
gap: 8px !important;
}
.actions-mobile :global(a),
.actions-mobile :global(button) {
padding: 6px 12px !important;
font-size: 14px !important;
border-radius: 8px !important;
}
.billing-cycle-header {
flex-direction: column;
gap: 8px;
}
:global([data-expandable-row-id='additional-projects']),
:global([data-expandable-row-id^='addon-']) {
background-color: var(--bgcolor-neutral-tertiary);
border-left: 3px solid var(--bgcolor-accent-neutral);
}
:global(.card-only-on-desktop) {
padding: 0.5rem;
box-shadow: unset;
@@ -0,0 +1,254 @@
<script lang="ts">
import { base } from '$app/paths';
import { CardGrid } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import type { Aggregation, Invoice, Plan } from '$lib/sdk/billing';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import {
Accordion,
Card,
Divider,
Icon,
Layout,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import { IconInfo, IconTag } from '@appwrite.io/pink-icons-svelte';
import CancelDowngradeModel from './cancelDowngradeModal.svelte';
export let currentPlan: Plan;
export let currentInvoice: Invoice | undefined = undefined;
export let availableCredit: number | undefined = undefined;
export let currentAggregation: Aggregation | undefined = undefined;
let showCancel: boolean = false;
const today = new Date();
const isTrial =
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
$plansInfo.get($organization.billingPlan)?.trialDays;
const extraUsage = currentInvoice ? currentInvoice.amount - currentPlan?.price : 0;
</script>
{#if $organization}
<CardGrid>
<svelte:fragment slot="title">Payment estimates</svelte:fragment>
A breakdown of your estimated upcoming payment for the current billing period. Totals displayed
exclude accumulated credits and applicable taxes.
<svelte:fragment slot="aside">
<p class="text u-bold">
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Card.Base variant="secondary" padding="s">
<Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{currentPlan.name} plan
</Typography.Text>
<Typography.Text>
{isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
? formatCurrency(0)
: currentPlan
? formatCurrency(currentPlan?.price)
: ''}
</Typography.Text>
</Layout.Stack>
{#if currentPlan.budgeting && extraUsage > 0}
<Accordion
hideDivider
title="Add-ons"
badge={(currentAggregation.additionalMembers > 0
? currentInvoice.usage.length + 1
: currentInvoice.usage.length
).toString()}>
<svelte:fragment slot="end">
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</svelte:fragment>
<Layout.Stack gap="xs">
{#if currentAggregation.additionalMembers}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary"
>Additional members</Typography.Text>
<Typography.Text>
{formatCurrency(
currentAggregation.additionalMemberAmount
)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Typography.Text
>{currentAggregation.additionalMembers}</Typography.Text>
</Layout.Stack>
</Layout.Stack>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
{#if i > 0 || currentAggregation.additionalMembers}
<Divider />
{/if}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{excess.name}
</Typography.Text>
<Typography.Text>
{formatCurrency(excess.amount)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Tooltip
placement="bottom"
disabled={excess.value <= 1000}>
<svelte:fragment slot="tooltip">
{formatNumberWithCommas(excess.value)}
</svelte:fragment>
<span>{abbreviateNumber(excess.value)}</span>
</Tooltip>
</Layout.Stack>
</Layout.Stack>
{/each}
{/if}
</Layout.Stack>
</Accordion>
{/if}
{#if currentPlan.supportsCredits && availableCredit > 0}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" gap="xxs">
<Icon size="s" icon={IconTag} color="--fgcolor-success" />
<Typography.Text color="--fgcolor-neutral-primary"
>Credits to be applied</Typography.Text>
</Layout.Stack>
<Typography.Text color="--fgcolor-success">
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</Typography.Text>
</Layout.Stack>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary" variant="m-500">
<Layout.Stack direction="row" alignItems="center" gap="s">
Current total (USD)
<Tooltip>
<Icon icon={IconInfo} />
<svelte:fragment slot="tooltip">
Estimates are updated daily and may differ from your
final invoice.
</svelte:fragment>
</Tooltip>
</Layout.Stack>
</Typography.Text>
<Typography.Text color="--fgcolor-neutral-primary" variant="m-500">
{formatCurrency(
Math.max(
(currentInvoice?.amount ?? 0) -
Math.min(availableCredit, currentInvoice?.amount ?? 0),
0
)
)}
</Typography.Text>
</Layout.Stack>
{/if}
</Layout.Stack>
</Card.Base>
</svelte:fragment>
<svelte:fragment slot="actions">
{#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
{#if !currentPlan?.usagePerProject}
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
{/if}
<Button
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
source: 'billing_tab'
})}>
Upgrade
</Button>
</div>
{:else}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
{#if $organization?.billingPlanDowngrade !== null}
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
{:else}
<Button
text
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
from: 'button',
source: 'billing_tab'
})}>
Change plan
</Button>
{/if}
{#if !currentPlan?.usagePerProject}
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
{/if}
</div>
{/if}
</svelte:fragment>
</CardGrid>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
--billing-card-border-color: hsl(var(--color-neutral-10));
}
:global(.card-only-on-desktop) {
background: hsl(var(--color-neutral-5));
border-radius: var(--corner-radius-medium, 8px);
border: 1px solid var(--billing-card-border-color);
}
:global(.theme-dark .card-only-on-desktop) {
background: #2c2c2f;
--billing-card-border-color: #424248;
}
:global(.card-only-on-desktop .collapsible-item:only-of-type) {
margin-block-start: 0.5rem;
border-block-start: solid 0.0625rem hsl(var(--p-toggle-border-color));
}
@media (max-width: 768px) {
:global(.card-only-on-desktop) {
padding: 0.5rem;
box-shadow: unset;
border-radius: unset;
/* yes, these `!important`s are needed */
border: unset !important;
background: unset !important;
}
}
</style>
@@ -17,9 +17,14 @@
Appwrite services, update the budget limit.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
<span class="text">View usage</span>
</Button>
{#if !page.data.currentPlan?.usagePerProject}
<Button
href={`${base}/organization-${$organization.$id}/usage`}
text
fullWidthMobile>
<span class="text">View usage</span>
</Button>
{/if}
<Button secondary fullWidthMobile href={redirectUrl}>
<span class="text">Update limit</span>
</Button>
@@ -4,7 +4,6 @@
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { PlanComparisonBox, PlanSelection, SelectPaymentMethod } from '$lib/components/billing';
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputTextarea } from '$lib/elements/forms';
@@ -33,7 +32,11 @@
import { onMount } from 'svelte';
import { loadAvailableRegions } from '$routes/(console)/regions';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
import OrganizationUsageLimits from '$lib/components/organizationUsageLimits.svelte';
import { Query } from '@appwrite.io/console';
import type { OrganizationUsage } from '$lib/sdk/billing';
import type { Models } from '@appwrite.io/console';
import { toLocaleDate } from '$lib/helpers/date';
export let data;
@@ -43,6 +46,9 @@
let previousPage: string = base;
let showExitModal = false;
let formComponent: Form;
let usageLimitsComponent:
| { validateOrAlert: () => boolean; getSelectedProjects: () => string[] }
| undefined;
let isSubmitting = writable(false);
let collaborators: string[] =
data?.members?.memberships
@@ -55,6 +61,8 @@
let showCreditModal = false;
let feedbackDowngradeReason: string;
let feedbackMessage: string;
let orgUsage: OrganizationUsage;
let allProjects: { projects: Models.Project[] } | undefined;
$: paymentMethods = null;
@@ -91,6 +99,21 @@
selectedPlan =
$currentPlan?.$id === BillingPlan.SCALE ? BillingPlan.SCALE : BillingPlan.PRO;
try {
orgUsage = await sdk.forConsole.billing.listUsage(data.organization.$id);
} catch {
orgUsage = undefined;
}
try {
allProjects = await sdk.forConsole.projects.list([
Query.equal('teamId', data.organization.$id),
Query.limit(1000)
]);
} catch {
allProjects = { projects: [] };
}
});
async function loadPaymentMethods() {
@@ -100,6 +123,16 @@
async function handleSubmit() {
if (isDowngrade) {
// If target plan has a non-zero project limit, ensure selection made
const targetProjectsLimit = $plansInfo?.get(selectedPlan)?.projects ?? 0;
const shouldShowProjectSelector =
targetProjectsLimit > 0 && allProjects.projects.length > targetProjectsLimit;
if (shouldShowProjectSelector && usageLimitsComponent?.validateOrAlert) {
const ok = usageLimitsComponent.validateOrAlert();
if (!ok) return;
}
await downgrade();
} else if (isUpgrade) {
await upgrade();
@@ -136,6 +169,7 @@
async function downgrade() {
try {
// 1) update the plan first
await sdk.forConsole.billing.updatePlan(
data.organization.$id,
selectedPlan,
@@ -143,6 +177,22 @@
null
);
// 2) If the target plan has a project limit, apply selected projects now
const targetProjectsLimit = $plansInfo?.get(selectedPlan)?.projects ?? 0;
if (targetProjectsLimit > 0 && usageLimitsComponent) {
const selected = usageLimitsComponent.getSelectedProjects();
if (selected?.length) {
try {
await sdk.forConsole.billing.updateSelectedProjects(
data.organization.$id,
selected
);
} catch (projectError) {
console.warn('Project selection failed after plan update:', projectError);
}
}
}
await Promise.all([trackDowngradeFeedback(), invalidate(Dependencies.ORGANIZATION)]);
await goto(previousPage);
@@ -298,24 +348,25 @@
<Alert.Inline
status="warning"
title="You can only have one free organization per account">
To downgrade this organization, first migrate or delete one of your
existing paid organizations.
<Button
compact
href="https://appwrite.io/docs/advanced/migrations/cloud"
>Migration guide</Button>
To downgrade this organization, first migrate or delete your existing
free organization.
<Layout.Stack gap="xs" direction="row" justifyContent="flex-start">
<Button
compact
external
href="https://appwrite.io/docs/advanced/migrations/cloud"
>Migration guide</Button>
</Layout.Stack>
</Alert.Inline>
{/if}
{#if isDowngrade}
{#if selectedPlan === BillingPlan.FREE && !data.hasFreeOrgs}
<PlanExcess tier={BillingPlan.FREE} />
{: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?.seats?.price ?? 0)
)}
{@const extraMembers = collaborators?.length ?? 0}
{@const price = formatCurrency(
extraMembers *
($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0)
)}
{#if selectedPlan === BillingPlan.PRO}
<Alert.Inline status="error">
<svelte:fragment slot="title">
Your monthly payments will be adjusted for the Pro plan
@@ -325,7 +376,26 @@
>you will be charged {price} monthly for {extraMembers} team members.</b>
This will be reflected in your next invoice.
</Alert.Inline>
{:else if selectedPlan === BillingPlan.FREE}
<Alert.Inline
status="error"
title={`Your organization will switch to ${tierToPlan(selectedPlan).name} plan on ${toLocaleDate(
$organization.billingNextInvoiceDate
)}`}>
You will retain access to {tierToPlan($organization.billingPlan)
.name} plan features until your billing period ends. After that,
<span class="u-bold"
>all team members except the owner will be removed,</span>
and service disruptions may occur if usage exceeds Free plan limits.
</Alert.Inline>
{/if}
<OrganizationUsageLimits
bind:this={usageLimitsComponent}
organization={data.organization}
projects={allProjects?.projects || []}
members={data.members?.memberships || []}
storageUsage={orgUsage?.storageTotal ?? 0} />
{/if}
</Layout.Stack>
</Fieldset>
@@ -1,11 +1,20 @@
import type { PageLoad } from './$types';
import type { Organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
export const load: PageLoad = async ({ depends, parent }) => {
const { members, currentPlan, organizations } = await parent();
depends(Dependencies.UPGRADE_PLAN);
let plans;
try {
plans = await sdk.forConsole.billing.listPlans();
} catch (error) {
console.error('Failed to load billing plans:', error);
plans = { plans: {} };
}
let plan: BillingPlan;
if (currentPlan?.$id === BillingPlan.SCALE) {
@@ -22,6 +31,7 @@ export const load: PageLoad = async ({ depends, parent }) => {
return {
members,
plan,
plans,
selfService,
hasFreeOrgs
};
@@ -66,7 +66,11 @@
event: 'usage',
title: 'Usage',
hasChildren: true,
disabled: !(isCloud && ($isOwner || $isBilling))
disabled: !(
isCloud &&
($isOwner || $isBilling) &&
!page.data.currentPlan?.usagePerProject
)
},
{
href: `${path}/billing`,
@@ -47,9 +47,7 @@
// Calculate if button should be disabled and tooltip should show
$: memberCount = data.organizationMembers?.total ?? 0;
$: isFreeWithMembers = $organization?.billingPlan === BillingPlan.FREE && memberCount >= 1;
$: isButtonDisabled = isCloud
? isFreeWithMembers || !$currentPlan?.addons?.seats?.supported
: false;
$: isButtonDisabled = isCloud ? isFreeWithMembers : false;
const resend = async (member: Models.Membership) => {
try {
@@ -8,7 +8,8 @@
getServiceLimit,
showUsageRatesModal,
type Tier,
upgradeURL
upgradeURL,
useNewPricingModal
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import ProjectBreakdown from './ProjectBreakdown.svelte';
@@ -76,14 +77,32 @@
{#if $organization.billingPlan === BillingPlan.SCALE}
<p class="text">
On the Scale plan, you'll be charged only for any usage that exceeds the thresholds per
resource listed below. <Link.Button on:click={() => ($showUsageRatesModal = true)}
>Learn more</Link.Button>
resource listed below.
{#if $useNewPricingModal}
<Link.Button on:click={() => ($showUsageRatesModal = true)}>Learn more</Link.Button>
{:else}
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">
Learn more
</Link.Anchor>
{/if}
</p>
{:else if $organization.billingPlan === BillingPlan.PRO}
<p class="text">
On the Pro plan, you'll be charged only for any usage that exceeds the thresholds per
resource listed below. <Link.Button on:click={() => ($showUsageRatesModal = true)}
>Learn more</Link.Button>
resource listed below.
{#if $useNewPricingModal}
<Link.Button on:click={() => ($showUsageRatesModal = true)}>Learn more</Link.Button>
{:else}
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">
Learn more
</Link.Anchor>
{/if}
</p>
{:else if $organization.billingPlan === BillingPlan.FREE}
<p class="text">
@@ -243,7 +243,7 @@
</CardGrid>
<CardGrid>
<svelte:fragment slot="title">Executions</svelte:fragment>
Calculated for all functions that are executed in all projects in your project.
Calculated for all functions that are executed in this project.
<svelte:fragment slot="aside">
{#if executions}
{@const current = formatNum(executionsTotal)}
@@ -1,4 +1,4 @@
import type { Aggregation, Invoice } from '$lib/sdk/billing';
import type { AggregationTeam, Invoice, InvoiceUsage } from '$lib/sdk/billing';
import { accumulateUsage } from '$lib/sdk/usage';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
@@ -11,7 +11,7 @@ export const load: PageLoad = async ({ params, parent }) => {
let startDate: string = organization.billingCurrentInvoiceDate;
let endDate: string = organization.billingNextInvoiceDate;
let currentInvoice: Invoice = undefined;
let currentAggregation: Aggregation = undefined;
let currentAggregation: AggregationTeam = undefined;
if (invoice) {
currentInvoice = await sdk.forConsole.billing.getInvoice(organization.$id, invoice);
@@ -22,6 +22,15 @@ export const load: PageLoad = async ({ params, parent }) => {
startDate = currentInvoice.from;
endDate = currentInvoice.to;
} else {
try {
currentAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
organization.billingAggregationId
);
} catch (e) {
// ignore error if no aggregation found
}
}
const [invoices, usage] = await Promise.all([
@@ -29,10 +38,24 @@ export const load: PageLoad = async ({ params, parent }) => {
sdk.forProject(region, project).project.getUsage({ startDate, endDate })
]);
if (invoice) {
usage.usersTotal = currentAggregation.usageUsers;
usage.executionsTotal = currentAggregation.usageExecutions;
usage.filesStorageTotal = currentAggregation.usageStorage;
if (currentAggregation) {
let projectSpecificData = null;
if (currentAggregation.breakdown) {
projectSpecificData = currentAggregation.breakdown.find((p) => p.$id === project);
}
if (projectSpecificData) {
const executionsResource = projectSpecificData.resources?.find?.(
(r: InvoiceUsage) => r.resourceId === 'executions'
);
if (executionsResource) {
usage.executionsTotal = executionsResource.value || usage.executionsTotal;
}
} else {
usage.usersTotal = currentAggregation.usageUsers;
usage.executionsTotal = currentAggregation.usageExecutions;
usage.filesStorageTotal = currentAggregation.usageStorage;
}
}
usage.users = accumulateUsage(usage.users, usage.usersTotal);
@@ -11,14 +11,12 @@
import { isServiceLimited } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { canWriteSites } from '$lib/stores/roles.js';
import { Card, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
import { app } from '$lib/stores/app';
import CreateSiteModal from './createSiteModal.svelte';
import EmptyLight from './(images)/empty-sites-light.svg';
import EmptyDark from './(images)/empty-sites-dark.svg';
import EmptyLightMobile from './(images)/empty-sites-light-mobile.svg';
import EmptyDarkMobile from './(images)/empty-sites-dark-mobile.svg';
import Grid from './grid.svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { columns } from './store';
@@ -28,15 +26,10 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { isSmallViewport } from '$lib/stores/viewport';
import { addNotification } from '$lib/stores/notifications';
import { isOnWaitlistSites, joinWaitlistSites } from '$lib/helpers/waitlist';
import { user } from '$lib/stores/user';
export let data;
let show = false;
let isOnWaitlist = isOnWaitlistSites($user);
$: $registerCommands([
{
@@ -57,118 +50,51 @@
onMount(() => {
return sdk.forConsole.client.subscribe('console', (response) => {
if (response.events.includes(`sites.*`)) {
if (response.events.includes('sites.*')) {
invalidate(Dependencies.SITES);
}
});
});
$: isDark = $app.themeInUse === 'dark';
$: imgSrc = isDark
? $isSmallViewport
? EmptyDarkMobile
: EmptyDark
: $isSmallViewport
? EmptyLightMobile
: EmptyLight;
$: imgClass = $isSmallViewport ? 'mobile' : 'desktop';
function addToWaitlist() {
joinWaitlistSites($user);
addNotification({
type: 'success',
title: 'Waitlist joined',
message: "We'll let you know as soon as Appwrite Sites is ready for you."
});
isOnWaitlist = true;
}
</script>
<Container>
{#if data.sitesLive}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery placeholder="Search by name" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector
{columns}
view={data.view}
hideColumns
hideView={!data.siteList.total} />
{#if $canWriteSites}
<Button on:mousedown={() => (show = true)} event="create_site" size="s">
<Icon icon={IconPlus} slot="start" size="s" />
Create site
</Button>
{/if}
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery placeholder="Search by name" />
</Layout.Stack>
{#if data.siteList.total}
{#if data.view === View.Grid}
<Grid siteList={data.siteList} />
{:else}
<Table siteList={data.siteList} />
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector {columns} view={data.view} hideColumns hideView={!data.siteList.total} />
{#if $canWriteSites}
<Button on:mousedown={() => (show = true)} event="create_site" size="s">
<Icon icon={IconPlus} slot="start" size="s" />
Create site
</Button>
{/if}
<PaginationWithLimit
name="Sites"
limit={data.limit}
offset={data.offset}
total={data.siteList.total} />
{:else if data.search}
<EmptySearch target="sites" />
</Layout.Stack>
</Layout.Stack>
{#if data.siteList.total}
{#if data.view === View.Grid}
<Grid siteList={data.siteList} />
{:else}
<Empty
single
allowCreate={$canWriteSites}
href="https://appwrite.io/docs/products/sites"
description="Deploy and manage your web applications with Sites. "
target="site"
src={$app.themeInUse === 'dark' ? EmptyDark : EmptyLight}
on:click={() => (show = true)}>
</Empty>
<Table siteList={data.siteList} />
{/if}
<PaginationWithLimit
name="Sites"
limit={data.limit}
offset={data.offset}
total={data.siteList.total} />
{:else if data.search}
<EmptySearch target="sites" />
{:else}
<Card.Base padding="m">
<Layout.Stack gap="xxl">
<img src={imgSrc} alt="create" aria-hidden="true" height="242" class={imgClass} />
<Layout.Stack>
{#if isOnWaitlist}
<Typography.Title size="s" align="center" color="--fgcolor-neutral-primary">
You've successfully joined the Sites waitlist
</Typography.Title>
<Typography.Text align="center" color="--fgcolor-neutral-secondary">
We can't wait for you to try out Sites on Cloud. You will get access
soon.
</Typography.Text>
{:else}
<Layout.Stack gap="m" alignItems="center">
<Typography.Title
size="s"
align="center"
color="--fgcolor-neutral-primary">
Appwrite Sites is in high demand
</Typography.Title>
<div style:max-width="600px">
<Typography.Text align="center" color="--fgcolor-neutral-secondary">
To ensure a smooth experience for everyone, were rolling out
access gradually. Join the waitlist and be one of the first to
deploy with Sites.
</Typography.Text>
</div>
<div style:margin-block-start="1rem">
<Button on:click={addToWaitlist}>Join waitlist</Button>
</div>
</Layout.Stack>
{/if}
</Layout.Stack>
</Layout.Stack>
</Card.Base>
<Empty
single
allowCreate={$canWriteSites}
href="https://appwrite.io/docs/products/sites"
description="Deploy and manage your web applications with Sites. "
target="site"
src={$app.themeInUse === 'dark' ? EmptyDark : EmptyLight}
on:click={() => (show = true)}>
</Empty>
{/if}
</Container>
@@ -1,12 +1,9 @@
import { Query, type Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, getSearch, getView, pageToOffset, View } from '$lib/helpers/load';
import { Query } from '@appwrite.io/console';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { flags } from '$lib/flags';
export const load = async ({ url, depends, route, params, parent }) => {
const data = await parent();
import { getLimit, getPage, getSearch, getView, pageToOffset, View } from '$lib/helpers/load';
export const load = async ({ url, depends, route, params }) => {
depends(Dependencies.SITES);
const page = getPage(url);
const search = getSearch(url);
@@ -14,22 +11,7 @@ export const load = async ({ url, depends, route, params, parent }) => {
const offset = pageToOffset(page, limit);
const view = getView(url, route, View.Grid, View.Grid);
if (!flags.showSites(data)) {
return {
sitesLive: false,
offset,
limit,
search,
view,
siteList: {
total: 0,
sites: []
} as Models.SiteList
};
}
return {
sitesLive: true,
offset,
limit,
search,