mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'main' into open-sheet
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 = {};
|
||||
|
||||
@@ -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!
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+6
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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)}
|
||||
|
||||
+29
-6
@@ -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, we’re 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,
|
||||
|
||||
Reference in New Issue
Block a user