Files
console/src/lib/components/archiveProject.svelte
T
Copilot 9cbf8d2c27 Differentiate archived project behavior by plan tier (#2549)
Prior to this, the projects page had two problems:

1. After submitting a downgrade request but still on a Pro plan before the end of the billing period, projects showed as archived
2. After downgraded, archived projects showed as read only and gave options to migrate data even though access to the archived projects were blocked.

This PR clarifies the behavior for archived projects by:

- showing projects as pending archive if downgraded but still on Pro
- removing the read only tag for downgraded orgs
- preventing opening projects for downgraded orgs since the user has no access 
- making the unarchive and migrate buttons disabled for downgraded orgs
2025-11-22 21:52:19 -08:00

359 lines
13 KiB
Svelte

<script lang="ts">
import { Button, InputText } from '$lib/elements/forms';
import { GridItem1, CardContainer, Modal } from '$lib/components';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import {
Badge,
Icon,
Typography,
Accordion,
ActionMenu,
Popover,
Layout,
Divider
} from '@appwrite.io/pink-svelte';
import {
IconAndroid,
IconApple,
IconCode,
IconFlutter,
IconReact,
IconUnity,
IconDotsHorizontal,
IconInboxIn,
IconSwitchHorizontal,
IconTrash
} 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 { 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 { 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();
// Check if current plan order is less than Pro (order < 1 means FREE plan)
let isPlanBelowPro = $derived(currentPlan?.order < 1);
let showUnarchiveModal = $state(false);
let projectToUnarchive = $state<Models.Project | null>(null);
let showDeleteModal = $state(false);
let projectToDelete = $state<Models.Project | null>(null);
let deleteProjectName = $state<string | null>(null);
let deleteError = $state<string | null>(null);
function resetDeleteState() {
showDeleteModal = false;
projectToDelete = null;
deleteProjectName = null;
deleteError = 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 (isPlanBelowPro) {
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;
}
function handleDeleteProject(project: Models.Project) {
projectToDelete = project;
deleteProjectName = null;
showDeleteModal = 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;
}
async function confirmDelete() {
if (!projectToDelete) return;
try {
await sdk.forConsoleIn(projectToDelete.region).projects.delete({
projectId: projectToDelete.$id
});
await invalidate(Dependencies.ORGANIZATION);
trackEvent(Submit.ProjectDelete);
addNotification({
type: 'success',
message: `${projectToDelete.name} has been deleted`
});
resetDeleteState();
} catch (error) {
deleteError = error.message;
trackError(error, Submit.ProjectDelete);
}
}
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={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
badge={`${projectsToArchive.length}`}>
<Typography.Text tag="p" size="s">
{#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}
</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">
<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}
disabled={isUnarchiveDisabled()}
on:click={() => handleMigrateProject(project)}
>Migrate project</ActionMenu.Item.Button>
<div class="action-menu-divider">
<Divider />
</div>
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={() => handleDeleteProject(project)}
>Delete 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>
<!-- Delete Confirmation Modal -->
<Modal
size="s"
bind:show={showDeleteModal}
title="Delete project"
onSubmit={confirmDelete}
bind:error={deleteError}>
<svelte:fragment slot="description">
The archived project <strong>{projectToDelete?.name}</strong> will be deleted along with all
of its metadata, stats, and other resources.
<b>This action is irreversible.</b>
</svelte:fragment>
<InputText
label={`Enter "${projectToDelete?.name}" to continue`}
placeholder="Enter name"
id="delete-project-name"
autofocus
required
bind:value={deleteProjectName} />
<svelte:fragment slot="footer">
<Button
text
on:click={() => {
resetDeleteState();
}}>Cancel</Button>
<Button
submissionLoader
submit
disabled={(deleteProjectName ?? '') !== projectToDelete?.name}>
Delete
</Button>
</svelte:fragment>
</Modal>
<style>
.archive-projects-margin-top {
margin-top: 36px;
}
.action-menu-divider {
margin-inline: -1rem;
padding-block-start: 0.25rem;
padding-block-end: 0.25rem;
}
.archive-projects-margin {
margin-top: 16px;
margin-bottom: 36px;
}
.status-container {
display: flex;
align-items: center;
gap: 8px;
}
</style>