diff --git a/src/lib/components/archiveProject.svelte b/src/lib/components/archiveProject.svelte
deleted file mode 100644
index 8a324b395b..0000000000
--- a/src/lib/components/archiveProject.svelte
+++ /dev/null
@@ -1,379 +0,0 @@
-
-
-{#if projectsToArchive.length > 0}
-
-
-
- {#if isPlanBelowPro}
- These projects are archived and require a plan upgrade to restore access.
- {:else}
- These projects will be archived at the end of your billing cycle.
- {/if}
-
-
-
-
- {#each projectsToArchive as project}
- {@const platforms = filterPlatforms(
- project.platforms.map((platform) => getPlatformInfo(platform.type))
- )}
- {@const formatted = formatName(project.name)}
-
-
- {project?.platforms?.length ? project?.platforms?.length : 'No'} apps
-
- {formatted}
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- toggle(e);
- }}>
-
-
-
- handleUnarchiveProject(project)}
- >Unarchive project
- handleMigrateProject(project)}
- >Migrate project
-
- handleDeleteProject(project)}
- >Delete project
-
-
-
-
-
- {#each platforms.slice(0, 2) as platform}
- {@const icon = getIconForPlatform(platform.icon)}
-
-
-
- {/each}
-
- {#if platforms.length > 2}
-
- {/if}
-
-
- {#if isCloud && $regionsStore?.regions}
- {@const region = findRegion(project)}
- {region?.name}
- {/if}
-
-
- {/each}
-
-
-
-
-
-
-{/if}
-
-
-
- Are you sure you want to unarchive {projectToUnarchive?.name} ?
- This will move the project back to your active projects list.
-
-
-
- Cancel
- Unarchive
-
-
-
-
-
-
-
- The archived project {projectToDelete?.name} will be deleted along with all
- of its metadata, stats, and other resources.
- This action is irreversible.
-
-
-
-
-
- {
- resetDeleteState();
- }}>Cancel
-
- Delete
-
-
-
-
-
diff --git a/src/lib/components/billing/alerts/projectsLimit.svelte b/src/lib/components/billing/alerts/projectsLimit.svelte
deleted file mode 100644
index 493e445107..0000000000
--- a/src/lib/components/billing/alerts/projectsLimit.svelte
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-{#if organizationId && $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
-
-
- Choose which projects to keep before {toLocaleDate(
- $organization.billingNextInvoiceDate
- )} or upgrade to Pro. Projects over the limit will be blocked after this date.
-
-
- {
- showSelectProject = true;
- }}>Manage projects
-
- Upgrade
-
-
-
-{/if}
diff --git a/src/lib/components/billing/alerts/selectProjectCloud.svelte b/src/lib/components/billing/alerts/selectProjectCloud.svelte
deleted file mode 100644
index a0084f97ab..0000000000
--- a/src/lib/components/billing/alerts/selectProjectCloud.svelte
+++ /dev/null
@@ -1,180 +0,0 @@
-
-
-
-
- Choose which {$currentPlan?.projects || 2} projects to keep. Projects over the limit will be
- blocked after this date.
-
-
- {#if loading}
-
-
-
- Project Name
- Created
-
-
- {#each Array.from({ length: 5 }) as _}
-
-
-
-
-
-
-
-
- {/each}
-
-
- {:else if projectsLoadingError}
- {projectsLoadingError}
- {:else}
- {#if error}
- {error}
- {/if}
-
-
-
- Project Name
- Created
-
- {#each projects as project}
-
- {project.name}
- {toLocaleDateTime(project.$createdAt)}
-
- {/each}
-
-
- {#if selectedProjects.length > $currentPlan?.projects}
-
- You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
-
- {/if}
-
- {#if selectedProjects.length === $currentPlan?.projects}
- {@const difference = projects.length - selectedProjects.length}
- {@const messagePrefix =
- difference > 1 ? `${difference} projects` : `${difference} project`}
-
-
- {@html formatProjectsToArchive()}
- will be archived.
-
-
- {/if}
- {/if}
-
- (showSelectProject = false)}
- >Cancel
- Save
-
-
-
-
diff --git a/src/lib/components/organizationUsageLimits.svelte b/src/lib/components/organizationUsageLimits.svelte
index fc97c9b257..39ae9a1ea3 100644
--- a/src/lib/components/organizationUsageLimits.svelte
+++ b/src/lib/components/organizationUsageLimits.svelte
@@ -1,5 +1,5 @@
@@ -220,11 +241,7 @@
{:else}
-
- {formatNumber(currentUsage.members)} / {formatNumber(
- freePlanLimits.members
- )}
-
+ N/A
{/if}
@@ -259,54 +276,90 @@
{#if showSelectProject}
-
-
- Choose which {freePlanLimits.projects} projects to keep. Projects over the limit will be
- blocked after your billing cycle ends on {toLocaleDate(
- $organization.billingNextInvoiceDate
- )}.
-
+ {@const requiredToDelete = currentUsage.projects - allowedProjectsToKeep}
+
+
+ The Free plan lets you keep {allowedProjectsToKeep} projects. Select projects you want to
+ permanently delete.
+
{#if error}
{error}
- {/if}
-
-
-
-
- Project Name
- Created
-
- {#each projects as project}
-
- {project.name}
-
- {toLocaleDateTime(project.$createdAt)}
-
-
- {/each}
-
-
- {#if selectedProjects.length === allowedProjectsToKeep}
- {@const difference = projects.length - selectedProjects.length}
- {@const messagePrefix =
- difference > 1 ? `${difference} projects` : `${difference} project`}
+ {:else}
- {formatProjectsToArchive()} will be archived
+ title="The selected projects will be permanently deleted">
+ The selected projects and all associated data will be permanently deleted and cannot
+ be recovered.
{/if}
+
+
+ Select {requiredToDelete} project{requiredToDelete !== 1 ? 's' : ''} to delete
+
+
+
+
+
+
+
+ Project Name
+ Created
+
+ {#each projects as project}
+ {@const isRowSelected = selectedProjectsToDelete.includes(project.$id)}
+ {@const shouldDisable =
+ !isRowSelected && selectedProjectsToDelete.length >= requiredToDelete}
+
+ {project.name}
+
+ {toLocaleDateTime(project.$createdAt)}
+
+
+ {/each}
+
+
+
+
+ {#if selectedProjectsToDelete.length >= requiredToDelete}
+
+
+
+ {/if}
+
(showSelectProject = false)}>Cancel
- Save
+ Delete projects
{/if}
@@ -352,4 +405,14 @@
min-width: 96px;
}
}
+
+ .controlled-selection :global([role='rowheader']) {
+ pointer-events: none;
+
+ :global([role='cell']) {
+ opacity: 0.5;
+ cursor: not-allowed;
+ color: var(--fgcolor-neutral-secondary);
+ }
+ }
diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts
index baf663114b..50e3a3071e 100644
--- a/src/lib/stores/billing.ts
+++ b/src/lib/stores/billing.ts
@@ -31,7 +31,6 @@ import { user } from './user';
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
-import ProjectsLimit from '$lib/components/billing/alerts/projectsLimit.svelte';
import EnterpriseTrial from '$routes/(console)/organization-[organization]/enterpriseTrial.svelte';
export const roles = [
@@ -368,32 +367,6 @@ export function calculateTrialDay(org: Models.Organization) {
return days;
}
-export async function checkForProjectsLimit(org: Models.Organization, orgProjectCount?: number) {
- if (!isCloud) return;
- if (!org) return;
-
- const plan = await sdk.forConsole.organizations.getPlan({
- organizationId: org.$id
- });
- if (!plan) return;
-
- if (!org.projects) return;
- if (org.projects.length > 0) return;
-
- const projectCount = orgProjectCount;
- if (projectCount === undefined) return;
-
- // not unlimited and current exceeds plan limits!
- if (plan.projects > 0 && projectCount > plan.projects) {
- headerAlert.add({
- id: 'projectsLimitReached',
- component: ProjectsLimit,
- show: true,
- importance: 12
- });
- }
-}
-
export async function checkForUsageLimit(organization: Models.Organization) {
if (
organization?.status === teamStatusReadonly &&
diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte
index 1b0230829a..c7a11414c0 100644
--- a/src/routes/(console)/+layout.svelte
+++ b/src/routes/(console)/+layout.svelte
@@ -18,7 +18,6 @@
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
- checkForProjectsLimit,
checkForUsageLimit,
checkPaymentAuthorizationRequired,
paymentExpired,
@@ -39,7 +38,7 @@
import { showSupportModal } from './wizard/support/store';
import { activeHeaderAlert, consoleVariables } from './store';
- import { base } from '$app/paths';
+ import { base, resolve } from '$app/paths';
import { headerAlert } from '$lib/stores/headerAlert';
import { UsageRates } from '$lib/components/billing';
import { canSeeProjects } from '$lib/stores/roles';
@@ -54,11 +53,8 @@
IconSparkles,
IconSwitchHorizontal
} from '@appwrite.io/pink-icons-svelte';
- import type { LayoutData } from './$types';
import type { Models } from '@appwrite.io/console';
- export let data: LayoutData;
-
function kebabToSentenceCase(str: string) {
return str
.split('-')
@@ -75,9 +71,7 @@
$: $registerCommands([
{
label: 'Go to Projects',
- callback: () => {
- goto(base);
- },
+ callback: () => goto(resolve('/')),
keys: ['g', 'p'],
group: 'navigation',
disabled:
@@ -296,9 +290,6 @@
if (currentOrganizationId === org.$id) return;
if (isCloud) {
currentOrganizationId = org.$id;
- const orgProjectCount =
- data.currentOrgId === org.$id ? data.allProjectsCount : undefined;
- await checkForProjectsLimit(org, orgProjectCount);
checkForEnterpriseTrial(org);
await checkForUsageLimit(org);
checkForMarkedForDeletion(org);
diff --git a/src/routes/(console)/organization-[organization]/+page.svelte b/src/routes/(console)/organization-[organization]/+page.svelte
index e10aa76c93..184a0ab878 100644
--- a/src/routes/(console)/organization-[organization]/+page.svelte
+++ b/src/routes/(console)/organization-[organization]/+page.svelte
@@ -7,7 +7,6 @@
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,
@@ -23,8 +22,7 @@
import { onMount, type ComponentType } from 'svelte';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
- import { Alert, Badge, Icon, Layout, Tag, Tooltip, Typography } from '@appwrite.io/pink-svelte';
- import { isSmallViewport } from '$lib/stores/viewport';
+ import { Alert, Badge, Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import {
IconAndroid,
IconApple,
@@ -38,14 +36,11 @@
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { regions as regionsStore } from '$lib/stores/organization';
- import SelectProjectCloud from '$lib/components/billing/alerts/selectProjectCloud.svelte';
- import ArchiveProject from '$lib/components/archiveProject.svelte';
let { data }: PageProps = $props();
let showCreate = $state(false);
let addOrganization = $state(false);
- let showSelectProject = $state(false);
let showCreateProjectCloud = $state(false);
let freePlanAlertDismissed = $state(false);
@@ -123,24 +118,7 @@
return $regionsStore.regions.find((region) => region.$id === project.region);
}
- function isSetToArchive(project: Models.Project): boolean {
- if (!isCloud) return false;
- if (!project || !project.$id) return false;
- return project.status === 'archived';
- }
-
- const projectsToArchive = $derived(
- (data.archivedProjectsPage ?? data.projects.projects).filter(
- (project) => project.status === 'archived'
- )
- );
-
- const activeTotalOverall = $derived(
- data?.activeTotalOverall ??
- data?.organization?.projects?.length ??
- data?.projects?.total ??
- 0
- );
+ const activeProjectsTotal = $derived(data?.projects.total);
function clearSearch() {
searchQuery?.clearInput();
@@ -162,11 +140,6 @@
});
-
-
@@ -196,30 +169,7 @@
{/if}
- {#if isCloud && data.currentPlan?.projects && data.currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > data.currentPlan.projects)}
- {@const difference = projectsToArchive.length}
- {@const messagePrefix =
- difference !== 1 ? `${difference} projects are` : `${difference} project is`}
-
- Upgrade your plan to restore archived projects
-
- {
- trackEvent(Click.OrganizationClickUpgrade, {
- from: 'button',
- source: 'projects_archive_alert'
- });
- }}>
- Upgrade to Pro
-
-
-
- {/if}
-
- {#if isCloud && !data.program && data.currentPlan?.projects !== 0 && projectsToArchive.length === 0 && !freePlanAlertDismissed}
+ {#if isCloud && !data.program && data.currentPlan?.projects && activeProjectsTotal <= data.currentPlan.projects && !freePlanAlertDismissed}
Your Free plan includes up to {data.currentPlan?.projects} projects and limited resources.
@@ -244,43 +194,20 @@
{#if data.projects.total > 0}
{#each data.projects.projects as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
)}
- {@const formatted = isSetToArchive(project)
- ? formatNameHelper(project.name, isSmallViewport ? 19 : 25)
- : project.name}
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
-
- {formatted}
-
- {project.name}
-
-
-
-
-
- {#if isSetToArchive(project)}
- {
- event.preventDefault();
- showSelectProject = true;
- }}>Set to archive
- {/if}
+ {project.name}
{#each platforms.slice(0, 2) as platform}
@@ -329,16 +256,7 @@
name="Projects"
limit={data.limit}
offset={data.offset}
- total={activeTotalOverall} />
-
-
-
+ total={activeProjectsTotal} />
diff --git a/src/routes/(console)/organization-[organization]/+page.ts b/src/routes/(console)/organization-[organization]/+page.ts
index fc4256b032..9a6c850238 100644
--- a/src/routes/(console)/organization-[organization]/+page.ts
+++ b/src/routes/(console)/organization-[organization]/+page.ts
@@ -5,12 +5,17 @@ import { getLimit, getPage, getSearch, pageToOffset } from '$lib/helpers/load';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';
-import { base } from '$app/paths';
+import { resolve } from '$app/paths';
export const load: PageLoad = async ({ params, url, route, depends, parent }) => {
const { scopes } = await parent();
if (!scopes.includes('projects.read') && scopes.includes('billing.read')) {
- return redirect(301, `${base}/organization-${params.organization}/billing`);
+ return redirect(
+ 301,
+ resolve('/(console)/organization-[organization]/billing', {
+ organization: params.organization
+ })
+ );
}
depends(Dependencies.ORGANIZATION);
@@ -20,76 +25,33 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
const offset = pageToOffset(page, limit);
const search = getSearch(url);
- const archivedPageRaw = parseInt(url.searchParams.get('archivedPage') || '1', 10);
- const archivedPage =
- Number.isFinite(archivedPageRaw) && archivedPageRaw > 0 ? archivedPageRaw : 1;
- const archivedOffset = pageToOffset(archivedPage, limit);
-
const searchQueries = search
? [Query.or([Query.search('search', search), Query.contains('labels', search)])]
: [];
- const commonQueries = [Query.equal('teamId', params.organization)];
const activeQueries = isCloud
? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])]
: [];
- const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([
- sdk.forConsole.projects.list({
- queries: [
- Query.offset(offset),
- Query.limit(limit),
- Query.orderDesc(''),
- ...commonQueries,
- ...searchQueries,
- ...activeQueries
- ]
- }),
- isCloud
- ? sdk.forConsole.projects.list({
- queries: [
- Query.offset(archivedOffset),
- Query.limit(limit),
- Query.orderDesc(''),
- ...commonQueries,
- ...searchQueries,
- Query.equal('status', 'archived')
- ]
- })
- : Promise.resolve({ projects: [], total: 0 }),
- sdk.forConsole.projects.list({
- queries: [...commonQueries, ...activeQueries, ...searchQueries]
- }),
- isCloud
- ? sdk.forConsole.projects.list({
- queries: [...commonQueries, ...searchQueries, Query.equal('status', 'archived')]
- })
- : Promise.resolve({ projects: [], total: 0 })
- ]);
+ const activeProjects = await sdk.forConsole.projects.list({
+ queries: [
+ ...searchQueries,
+ ...activeQueries,
+ Query.offset(offset),
+ Query.limit(limit),
+ Query.orderDesc(''),
+ Query.equal('teamId', params.organization)
+ ]
+ });
// set `default` if no region!
for (const project of activeProjects.projects) {
project.region ??= 'default';
}
- if (isCloud) {
- for (const project of archivedProjects.projects) {
- project.region ??= 'default';
- }
- }
return {
- offset,
limit,
- projects: {
- ...activeProjects,
- projects: activeProjects.projects,
- total: activeTotal.total
- },
- activeProjectsPage: activeProjects.projects,
- archivedProjectsPage: archivedProjects.projects,
- activeTotalOverall: activeTotal.total,
- archivedTotalOverall: archivedTotal.total,
- archivedOffset,
- archivedPage,
- search
+ offset,
+ search,
+ projects: activeProjects
};
};
diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte
index 850f9fb11b..80d585adfa 100644
--- a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte
+++ b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte
@@ -46,9 +46,6 @@
let previousPage: string = resolve('/');
let showExitModal = false;
let formComponent: Form;
- let usageLimitsComponent:
- | { validateOrAlert: () => boolean; getSelectedProjects: () => string[] }
- | undefined;
let isSubmitting = writable(false);
let collaborators: string[] =
data?.members?.memberships
@@ -62,7 +59,7 @@
let feedbackDowngradeReason: string;
let feedbackMessage: string;
let orgUsage: Models.UsageOrganization;
- let allProjects: { projects: Models.Project[] } | undefined;
+ let allProjects: { projects: Models.Project[] } = { projects: [] };
$: paymentMethods = null;
@@ -132,18 +129,15 @@
return paymentMethods;
}
+ function hasExcessProjectsForFreePlan(): boolean {
+ const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
+ const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
+ const currentProjectCount = allProjects.projects.length;
+ return currentProjectCount > freePlanProjectLimit;
+ }
+
async function handleSubmit() {
if (isDowngrade) {
- // If target plan has a non-zero project limit, ensure selection made
- const targetProjectsLimit = 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();
@@ -169,6 +163,18 @@
}
async function downgrade() {
+ if (selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjectsForFreePlan()) {
+ const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
+ const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
+ const currentProjectCount = allProjects?.projects?.length ?? 0;
+
+ addNotification({
+ type: 'error',
+ message: `Please delete ${currentProjectCount - freePlanProjectLimit} project${currentProjectCount - freePlanProjectLimit !== 1 ? 's' : ''} before downgrading`
+ });
+ return;
+ }
+
try {
// 1) update the plan first
await sdk.forConsole.organizations.updatePlan({
@@ -177,22 +183,6 @@
paymentMethodId
});
- // 2) If the target plan has a project limit, apply selected projects now
- const targetProjectsLimit = selectedPlan?.projects ?? 0;
- if (targetProjectsLimit > 0 && usageLimitsComponent) {
- const selected = usageLimitsComponent.getSelectedProjects();
- if (selected?.length) {
- try {
- await sdk.forConsole.organizations.updateProjects({
- organizationId: data.organization.$id,
- projects: selected
- });
- } catch (projectError) {
- console.warn('Project selection failed after plan update:', projectError);
- }
- }
- }
-
await Promise.all([trackDowngradeFeedback(), invalidate(Dependencies.ORGANIZATION)]);
await goto(previousPage);
@@ -319,9 +309,19 @@
$: isUpgrade = selectedPlan.order > $currentPlan?.order;
$: isDowngrade = selectedPlan.order < $currentPlan?.order;
- $: isButtonDisabled =
- $organization?.billingPlanId === selectedPlan.$id ||
- (isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && data.hasFreeOrgs);
+
+ // Check if projects exceed Free plan limit when downgrading
+ $: isButtonDisabled = (() => {
+ const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
+ const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
+ const hasExcessProjects = allProjects.projects.length > freePlanProjectLimit;
+
+ if ($organization?.billingPlanId === selectedPlan.$id) return true;
+ if (isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && data.hasFreeOrgs)
+ return true;
+
+ return isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjects;
+ })();
@@ -401,9 +401,8 @@
{/if}
{/if}
diff --git a/src/routes/(console)/project-[region]-[project]/+layout.ts b/src/routes/(console)/project-[region]-[project]/+layout.ts
index 26683eddaa..01700f3795 100644
--- a/src/routes/(console)/project-[region]-[project]/+layout.ts
+++ b/src/routes/(console)/project-[region]-[project]/+layout.ts
@@ -18,6 +18,16 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
depends(Dependencies.PROJECT);
const project = await sdk.forConsole.projects.get({ projectId: params.project });
+ if (project.status !== 'active') {
+ // project isn't active, redirect back to organizations page
+ redirect(
+ 303,
+ resolve('/(console)/organization-[organization]', {
+ organization: project.teamId
+ })
+ );
+ }
+
project.region ??= 'default';
// fast path without a network call!