-
Notifications
You must be signed in to change notification settings - Fork 186
Feat: New Billing UI changes #2249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
47fe4f6
6de7226
60b61fe
449fe5a
42bc0c8
9b04d01
6f0c948
6cf440c
4572379
ee293bc
10ab144
644b3ce
0f0c76b
4498077
5cd65a3
8a1df95
00890b9
31c4e14
9317f1b
80a8632
25c45e4
19e6dea
f9ee8c2
475e24a
339abe7
84c101b
7e11182
7270f41
f90cec4
ed1bffd
fb1a19a
3c9ae86
aa56721
f0fd49e
dcfd692
0f32da1
1ac6cd9
2b937ca
4e92e3b
5e134e2
a8d3f02
49ad425
ceed8f2
1228ee2
2530009
637de1c
7b68969
4232152
f3c4246
7b5e65d
6e23bb5
f03aa84
333ad68
fce7e92
b0625be
e3272cc
bed7916
2477164
53a2289
2df7df2
6982ae6
c0618e4
fde80f4
d0a1b8c
c321055
1e45894
215dcc8
b641df1
ce06db5
5cedece
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -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} | ||||||||||||||||||||||||||
Comment on lines
+244
to
+249
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Off-by-one: "+N" badge hidden when there are exactly 3 platforms You render two badges, so the overflow badge should show when length > 2, not > 3. - {#if platforms.length > 3}
+ {#if platforms.length > 2}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
style="width: max-content;" />
{/if} 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
<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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against undefined project.platforms before mapping
project.platforms may be undefined; calling .map() will throw at runtime. Use nullish-coalescing before mapping.
📝 Committable suggestion
🤖 Prompt for AI Agents