Merge branch 'feat-profiles' into fix-early-init-crash

This commit is contained in:
Torsten Dittmann
2025-12-04 18:37:22 +04:00
committed by GitHub
32 changed files with 783 additions and 498 deletions
+4 -4
View File
@@ -12,7 +12,7 @@
"clean": "rm -rf node_modules && rm -rf .svelte_kit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write --cache .",
"format": "prettier --cache --write .",
"lint": "prettier --check . && eslint .",
"test": "TZ=EST vitest run",
"test:ui": "TZ=EST vitest --ui",
@@ -22,11 +22,11 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@406d8be",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@4838219",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@5b26bb8",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^10.25.0",
+274 -281
View File
File diff suppressed because it is too large Load Diff
@@ -1,103 +1,220 @@
<script lang="ts">
import { page } from '$app/state';
import { BillingPlan } from '$lib/constants';
import type { Plan } from '$lib/sdk/billing';
import { formatNum } from '$lib/helpers/string';
import { plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { isFreePlan, isPaidPlan } from '$lib/helpers/billing';
import { plansInfo, type Tier } from '$lib/stores/billing';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
import { Card, Layout, Tabs, Typography } from '@appwrite.io/pink-svelte';
export let downgrade = false;
let {
downgrade = false
}: {
downgrade?: boolean;
} = $props();
let selectedTab: Tier = BillingPlan.FREE;
let selectedTab: Tier = $state(resolvedProfile.freeTier as Tier);
$: plan = $plansInfo.get(selectedTab);
const currentPlan: Plan = $derived($plansInfo.get(selectedTab));
const visiblePlans: Plan[] = $derived.by(() => {
return page.data.plans.plans.filter((plan: Plan) => plan.$id !== BillingPlan.SCALE);
});
const allTiers: Tier[] = [BillingPlan.FREE, BillingPlan.PRO, BillingPlan.SCALE];
$: visibleTiers = allTiers.filter((tier) => tier !== BillingPlan.SCALE);
const uniquePlans: Plan[] = $derived.by(() => {
const map = new Map(visiblePlans.map((p) => [p.group ?? p.$id, p]));
return [...map.values()];
});
const groupPlans = $derived.by(() => {
const selected = visiblePlans.find((p: Plan) => p.$id === selectedTab);
if (!selected) return [];
const groupKey = selected.group ?? selected.$id;
return visiblePlans.filter((p: Plan) => (p.group ?? p.$id) === groupKey);
});
// get the smallest in the group!
const basePaidPlan = $derived.by(() => {
return groupPlans.length > 1
? [...groupPlans].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0]
: currentPlan;
});
function getCleanPlanName(plan: Plan) {
return plan?.name?.replace(resolvedProfile.platform, '');
}
function pluralize(count: number, singular: string, plural?: string) {
if (count === 1) return singular;
return plural ?? `${singular}s`;
}
function selectPlan(plan: string) {
selectedTab = plan as Tier;
}
</script>
<Card.Base>
<Layout.Stack>
<Tabs.Root stretch let:root>
{#each visibleTiers as tier}
{#each uniquePlans as plan}
<Tabs.Item.Button
{root}
active={selectedTab === tier}
on:click={() => (selectedTab = tier)}>
{tierToPlan(tier).name}
active={selectedTab === plan.$id}
on:click={() => selectPlan(plan.$id)}>
{getCleanPlanName(plan)}
</Tabs.Item.Button>
{/each}
</Tabs.Root>
<Typography.Text variant="m-600">{plan.name} plan</Typography.Text>
{#if selectedTab === BillingPlan.FREE}
{#if downgrade}
<ul class="u-margin-block-start-8 list u-gap-4 u-small">
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions}
Functions per project
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text"> Limited to 1 organization member </span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{plan.bandwidth}GB bandwidth
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{plan.storage}GB storage
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{formatNum(plan.executions)} executions
</span>
</li>
</ul>
{:else}
<ul class="un-order-list">
<li>
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions}
Functions per project
</li>
<li>Limited to 1 organization member</li>
<li>
Limited to {plan.bandwidth}GB bandwidth
</li>
<li>
Limited to {plan.storage}GB storage
</li>
<li>
Limited to {formatNum(plan.executions)} executions
</li>
</ul>
{/if}
{:else if selectedTab === BillingPlan.PRO}
<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>
<li>Email support</li>
</ul>
{:else if selectedTab === BillingPlan.SCALE}
<Typography.Text>Everything in the Pro plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited seats</li>
<li>Organization roles</li>
<li>SOC-2, HIPAA compliance</li>
<li>SSO <span class="inline-tag">Coming soon</span></li>
<li>Priority support</li>
</ul>
<Typography.Text variant="m-600">{getCleanPlanName(currentPlan)} plan</Typography.Text>
{#if resolvedProfile.id === ProfileMode.CONSOLE}
{@render appwritePlanView()}
{:else}
{@render imaginePlanView()}
{/if}
</Layout.Stack>
</Card.Base>
{#snippet appwritePlanView()}
{#if isFreePlan(selectedTab)}
{#if downgrade}
<ul class="u-margin-block-start-8 list u-gap-4 u-small">
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
Limited to {currentPlan.databases}
{pluralize(currentPlan.databases, 'Database')}, {currentPlan.buckets}
{pluralize(currentPlan.buckets, 'Bucket')}, {currentPlan.functions}
{pluralize(currentPlan.functions, 'Function')} per project
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text"> Limited to 1 organization member </span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{currentPlan.bandwidth}GB bandwidth
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{currentPlan.storage}GB storage
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{formatNum(currentPlan.executions)} executions
</span>
</li>
</ul>
{:else}
<ul class="un-order-list">
<li>
Limited to {currentPlan.databases}
{pluralize(currentPlan.databases, 'Database')}, {currentPlan.buckets}
{pluralize(currentPlan.buckets, 'Bucket')}, {currentPlan.functions}
{pluralize(currentPlan.functions, 'Function')} per project
</li>
<li>Limited to 1 organization member</li>
<li>
Limited to {currentPlan.bandwidth}GB bandwidth
</li>
<li>
Limited to {currentPlan.storage}GB storage
</li>
<li>
Limited to {formatNum(currentPlan.executions)} executions
</li>
</ul>
{/if}
{:else if isPaidPlan(selectedTab)}
<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>{currentPlan.bandwidth}GB bandwidth</li>
<li>{currentPlan.storage}GB storage</li>
<li>{formatNum(currentPlan.executions)} executions</li>
<li>Email support</li>
</ul>
{:else if selectedTab === BillingPlan.SCALE}
<Typography.Text>Everything in the Pro plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited seats</li>
<li>Organization roles</li>
<li>SOC-2, HIPAA compliance</li>
<li>SSO <span class="inline-tag">Coming soon</span></li>
<li>Priority support</li>
</ul>
{/if}
{/snippet}
{#snippet imaginePlanView()}
{#if isFreePlan(selectedTab)}
{#if downgrade}
<ul class="u-margin-block-start-8 list u-gap-4 u-small">
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">
{currentPlan?.limits?.dailyCredits ?? currentPlan?.limits?.credits} credits /
day
</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">{currentPlan.bandwidth}GB bandwidth</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">{currentPlan.storage}GB storage</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text"
>{currentPlan.databases}
{pluralize(currentPlan.databases, 'database')} per project</span>
</li>
<li class="list-item u-gap-4 u-cross-center">
<span class="icon-arrow-down u-color-text-danger" aria-hidden="true"></span>
<span class="text">Community support</span>
</li>
</ul>
{:else}
<ul class="un-order-list">
<li>
{currentPlan?.limits?.dailyCredits ?? currentPlan?.limits?.credits} credits / day
</li>
<li>{currentPlan.bandwidth}GB bandwidth</li>
<li>{currentPlan.storage}GB storage</li>
<li>
{currentPlan.databases}
{pluralize(currentPlan.databases, 'database')} per project
</li>
<li>Community support</li>
</ul>
{/if}
{:else if isPaidPlan(selectedTab)}
<ul class="un-order-list">
<li>
{basePaidPlan?.limits?.credits?.toLocaleString()} credits / month
{#if basePaidPlan?.usage?.credits}
(${basePaidPlan.usage.credits.price} per {basePaidPlan.usage.credits.value} additional
credits)
{/if}
</li>
<li>{currentPlan.bandwidth / 1000}TB bandwidth</li>
<li>{currentPlan.storage}GB storage</li>
<li>Unlimited databases</li>
<li>Removable {resolvedProfile.platform} branding</li>
<li>Email support</li>
</ul>
{/if}
{/snippet}
+144 -20
View File
@@ -6,43 +6,161 @@
import { LabelCard } from '..';
import type { Plan } from '$lib/sdk/billing';
import { page } from '$app/state';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
import { InputSelect } from '$lib/elements/forms';
export let billingPlan: BillingPlan;
export let disabled = false;
export let isNewOrg = false;
export let selfService = true;
export let anyOrgFree = false;
export let billingPlan: BillingPlan;
$: plans = Object.values(page.data.plans.plans) as Plan[];
$: currentPlanInList = plans.some((plan) => plan.$id === $currentPlan?.$id);
let selectedTiers: Record<string, string> = {};
// experiment to remove scale plan temporarily
$: plansWithoutScale = plans.filter((plan) => plan.$id != BillingPlan.SCALE);
type PlanGroup = {
key: string;
plans: Plan[];
isGrouped: boolean;
tierOptions?: { value: string; label: string; badge?: string }[];
};
$: groupedPlans = (() => {
const plans = Object.values(page.data.plans.plans) as Plan[];
const groups = filterAndGroupPlans(plans);
return buildPlanGroups(groups);
})();
$: currentPlanInList = Object.values(page.data.plans.plans).some(
(plan: Plan) => plan.$id === $currentPlan?.$id
);
$: {
for (const group of groupedPlans) {
if (group.isGrouped) {
selectedTiers[group.key] = getDefaultTierForGroup(group);
}
}
}
function filterAndGroupPlans(plans: Plan[]): Map<string, Plan[]> {
const groups = new Map<string, Plan[]>();
for (const plan of plans) {
if (plan.$id === BillingPlan.SCALE) continue;
const key = plan.group ?? plan.$id;
const existing = groups.get(key);
if (existing) {
existing.push(plan);
} else {
groups.set(key, [plan]);
}
}
return groups;
}
function buildPlanGroups(groups: Map<string, Plan[]>): PlanGroup[] {
return Array.from(groups.entries())
.map(([key, groupPlans]) => {
const isGrouped = groupPlans.length > 1;
const group: PlanGroup = { key, plans: groupPlans, isGrouped };
if (isGrouped) {
group.tierOptions = buildTierOptions(groupPlans);
}
return group;
})
.sort((a, b) => (a.plans[0]?.order ?? 0) - (b.plans[0]?.order ?? 0));
}
function buildTierOptions(plans: Plan[]): { value: string; label: string; badge?: string }[] {
return plans.map((plan) => ({
value: plan.$id,
label: getPlanLabel(plan),
badge: $organization?.billingPlan === plan.$id ? 'Current' : undefined
}));
}
function getDefaultTierForGroup(group: PlanGroup): string {
// billingPlan if in group, else org's current plan, else first plan
const selectedGroupPlan = group.plans.find((p) => p.$id === billingPlan);
if (selectedGroupPlan) {
return selectedGroupPlan.$id;
}
const orgCurrentPlan = group.plans.find((p) => p.$id === $organization?.billingPlan);
if (orgCurrentPlan) {
return orgCurrentPlan.$id;
}
return selectedTiers[group.key] || group.plans[0].$id;
}
function getPlanLabel(plan: Plan): string {
const price = formatCurrency(plan?.price ?? 0);
if (resolvedProfile.id === ProfileMode.STUDIO) {
if (plan.limits?.dailyCredits) {
return `${plan.limits.dailyCredits.toLocaleString()} daily credits for ${price} per month`;
} else if (plan.limits?.credits) {
return `${plan.limits.credits.toLocaleString()} credits per month for ${price}`;
}
}
return `${price} per month`;
}
function handleTierChange(group: PlanGroup) {
billingPlan = selectedTiers[group.key] as BillingPlan;
}
</script>
<Layout.Stack>
{#each plansWithoutScale as plan}
{#each groupedPlans as group (group.key)}
{@const basePlan = group.plans[0]}
<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}>
disabled={!selfService || (basePlan.$id === BillingPlan.FREE && anyOrgFree) || disabled}
tooltipShow={basePlan.$id === BillingPlan.FREE && anyOrgFree}
value={group.isGrouped ? selectedTiers[group.key] || basePlan.$id : basePlan.$id}
title={basePlan.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
{#if group.plans.some((plan) => $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>
<Layout.Stack direction="column" gap="m">
<Layout.Stack direction="column" gap="xxs">
<Typography.Caption variant="400">
{basePlan.desc}
</Typography.Caption>
{#if !group.isGrouped}
<Typography.Text>
{@const isZeroPrice = (basePlan.price ?? 0) <= 0}
{@const price = formatCurrency(basePlan.price ?? 0)}
{#if resolvedProfile.id === ProfileMode.STUDIO}
{getPlanLabel(basePlan)}
{:else}
{isZeroPrice ? price : getPlanLabel(basePlan)}
{/if}
</Typography.Text>
{/if}
</Layout.Stack>
{#if group.isGrouped}
<InputSelect
id="tier-{group.key}"
bind:value={selectedTiers[group.key]}
on:change={() => handleTierChange(group)}
options={group.tierOptions} />
{/if}
</Layout.Stack>
</LabelCard>
{/each}
{#if $currentPlan && !currentPlanInList}
<LabelCard
name="plan"
@@ -54,14 +172,20 @@
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{$currentPlan.desc}
</Typography.Caption>
<Typography.Text>
{@const isZeroPrice = ($currentPlan?.price ?? 0) <= 0}
{@const price = formatCurrency($currentPlan?.price ?? 0)}
{isZeroPrice ? price : `${price} per month + usage`}
{isZeroPrice ? price : getPlanLabel($currentPlan)}
</Typography.Text>
</LabelCard>
{/if}
<Typography.Text style="margin-left: 6px;" variant="m-400">
Additional charges apply when you <b>exceed</b> plan limits.
</Typography.Text>
</Layout.Stack>
+1 -1
View File
@@ -118,7 +118,7 @@
</div>
</div>
<svelte:fragment slot="footer">
<Button text external href="https://appwrite.io/privacy">Privacy Policy</Button>
<Button text external href="{resolvedProfile.website}/privacy">Privacy Policy</Button>
<Button on:click={() => confirmChoices(selected)}>Save preferences</Button>
</svelte:fragment>
</Modal>
@@ -1,7 +1,6 @@
<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';
@@ -13,6 +12,7 @@
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { organization, type Organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
import { resolvedProfile } from '$lib/profiles/index.svelte';
// Props
type Props = {
@@ -30,37 +30,37 @@
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)
const freePlanLimits = $derived({
projects: $plansInfo?.get(resolvedProfile.freeTier)?.projects,
members: getServiceLimit('members', resolvedProfile.freeTier),
storage: getServiceLimit('storage', resolvedProfile.freeTier)
});
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
let allowedProjectsToKeep = $derived(freePlanLimits.projects);
const allowedProjectsToKeep = $derived(freePlanLimits.projects);
let currentUsage = $derived({
const currentUsage = $derived({
projects: projects?.length || 0,
members: members?.length || 0,
storage: storageUsage || 0
});
let storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
const storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
let isLimitExceeded = $derived({
const isLimitExceeded = $derived({
projects: currentUsage.projects > freePlanLimits.projects,
members: currentUsage.members > freePlanLimits.members,
storage: storageUsageGB > freePlanLimits.storage
});
let excessUsage = $derived({
const 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(
const projectsToArchive = $derived(
projects.filter((project) => !selectedProjects.includes(project.$id))
);
@@ -113,7 +113,9 @@
error = `You must select exactly ${allowedProjectsToKeep} projects to keep.`;
return;
}
// Keep selection locally; parent flow will apply after plan change
// Keep selection locally;
// parent flow will apply after plan change
showSelectProject = false;
showSelectionReminder = false;
addNotification({ type: 'success', message: `Projects selected for archiving` });
@@ -319,7 +321,7 @@
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
position: relative;
z-index: 2;
/*z-index: 2;*/
}
/* Small viewport optimizations */
@@ -327,7 +329,7 @@
.responsive-table {
margin-inline: -1rem;
padding-inline: 1rem;
z-index: 5;
/*z-index: 5;*/
}
.responsive-table :global(td),
+1 -1
View File
@@ -55,7 +55,7 @@
cta: 'Discord',
showSupport: false,
label: 'Community support',
link: 'https://appwrite.io/discord',
link: `${resolvedProfile.discord}`,
description: 'Get support from our community through Discord'
},
...(!resolvedProfile.showGithubIssueSupport
+4
View File
@@ -9,3 +9,7 @@ export function isFreePlan(plan: BillingPlan | string): boolean {
return false;
}
}
export function isPaidPlan(plan: BillingPlan | string): boolean {
return !isFreePlan(plan);
}
+4 -4
View File
@@ -99,7 +99,7 @@
<Link.Anchor
size="s"
variant="quiet"
href="https://appwrite.io/docs"
href="{resolvedProfile.website}/docs"
target="_blank"
rel="noreferrer">
Docs
@@ -110,7 +110,7 @@
<Link.Anchor
size="s"
variant="quiet"
href="https://appwrite.io/terms"
href="{resolvedProfile.website}/terms"
target="_blank"
rel="noreferrer">
Terms
@@ -121,7 +121,7 @@
<Link.Anchor
size="s"
variant="quiet"
href="https://appwrite.io/privacy"
href="{resolvedProfile.website}/privacy"
target="_blank"
rel="noreferrer">
Privacy
@@ -133,7 +133,7 @@
<Link.Anchor
size="s"
variant="quiet"
href="https://appwrite.io/cookies"
href="{resolvedProfile.website}/cookies"
target="_blank"
rel="noreferrer">
Cookies
+7 -1
View File
@@ -54,6 +54,8 @@ export type Profile = {
sites: boolean;
settings: boolean;
};
discord: string;
website: string;
showOrgInBreadcrumbs: boolean;
minimalOrgHeader: boolean;
getProjectRoute: (params: { region: string; project: string }) => ResolvedPathname;
@@ -101,6 +103,8 @@ export const base: Profile = {
sites: true,
settings: true
},
discord: 'https://appwrite.io/discord',
website: 'https://appwrite.io',
showOrgInBreadcrumbs: true,
minimalOrgHeader: false,
getProjectRoute({ region, project }) {
@@ -127,7 +131,7 @@ export const studio: Profile = {
logins: [/** temporary */ Logins.EMAIL, Logins.GITHUB, Logins.GOOGLE].filter(Boolean),
oauthProviders: {
github: OAuthProvider.GithubImagine,
google: OAuthProvider.Google
google: OAuthProvider.GoogleImagine
},
css: StudioCss,
component: {
@@ -151,6 +155,8 @@ export const studio: Profile = {
sites: false,
settings: true
},
website: 'https://imagine.dev',
discord: 'https://imagine.dev/discord',
showOrgInBreadcrumbs: false,
minimalOrgHeader: true,
getProjectRoute({ region, project }) {
+12 -16
View File
@@ -1,7 +1,8 @@
import type { Tier } from '$lib/stores/billing';
import type { Campaign } from '$lib/stores/campaigns';
import type { Client, Models, Platform } from '@appwrite.io/console';
import { BillingPlanGroup, type Client, type Models, type Platform } from '@appwrite.io/console';
import type { PaymentMethod } from '@stripe/stripe-js';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import type { Organization, OrganizationError, OrganizationList } from '../stores/organization';
export type PaymentMethodData = {
@@ -411,6 +412,9 @@ export type Plan = {
databasesWrites: AdditionalResource;
GBHours: AdditionalResource;
imageTransformations: AdditionalResource;
// imagine specific
credits: AdditionalResource;
};
addons: {
seats: PlanAddon;
@@ -431,6 +435,11 @@ export type Plan = {
buildSize: number; // in MB
deploymentSize: number; // in MB
usagePerProject: boolean;
limits: {
credits?: number;
dailyCredits?: number;
};
group: null | BillingPlanGroup;
};
export type PlanList = {
@@ -581,7 +590,8 @@ export class Billing {
const path = `/console/plans`;
const uri = new URL(this.client.config.endpoint + path);
const params = {
queries
queries,
platform: resolvedProfile.platform.toLowerCase()
};
return await this.client.call(
'get',
@@ -1441,18 +1451,4 @@ export class Billing {
params
);
}
async getPlansInfo(): Promise<PlanList> {
const path = `/console/plans`;
const params = {};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'GET',
uri,
{
'content-type': 'application/json'
},
params
);
}
}
+8 -3
View File
@@ -40,8 +40,9 @@ 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 { isFreePlan } from '$lib/helpers/billing';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
import { isFreePlan, isPaidPlan } from '$lib/helpers/billing';
import { BillingPlan as CloudSdkBillingPlan } from '@appwrite.io/console';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
@@ -153,7 +154,11 @@ export type PlanServices =
| 'authPhone'
| 'imageTransformations';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
export function getServiceLimit(
serviceId: PlanServices,
tier: Tier | CloudSdkBillingPlan = null,
plan?: Plan
): number {
if (!isCloud) return 0;
if (!serviceId) return 0;
@@ -266,7 +271,7 @@ 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) {
if (isPaidPlan(currentTier)) {
return false; // No project limitation for members on Pro/Scale plans
}
}
+4 -3
View File
@@ -58,6 +58,7 @@
import type { LayoutData } from './$types';
import Studio from '$lib/studio/studio.svelte';
import { isFreePlan } from '$lib/helpers/billing';
import { resolvedProfile } from '$lib/profiles/index.svelte';
export let data: LayoutData;
@@ -110,7 +111,7 @@
{
label: 'Open documentation',
callback: () => {
window.open('https://appwrite.io/docs', '_blank');
window.open(`${resolvedProfile.website}/docs`, '_blank');
},
group: 'help',
icon: IconBookOpen
@@ -118,7 +119,7 @@
{
label: 'Contact support',
callback: () => {
window.open('https://appwrite.io/discord', '_blank');
window.open(`${resolvedProfile.website}/discord`, '_blank');
},
group: 'help',
icon: IconQuestionMarkCircle
@@ -134,7 +135,7 @@
{
label: 'Join Discord community',
callback: () => {
window.open('https://appwrite.io/discord', '_blank');
window.open('${resolvedProfile.website}/discord', '_blank');
},
group: 'help',
icon: IconDiscord
+1 -1
View File
@@ -17,7 +17,7 @@ export const load: LayoutLoad = async ({ depends, parent }) => {
const { endpoint, project } = sdk.forConsole.client.config;
const [preferences, plansArray, versionData, consoleVariables] = await Promise.all([
sdk.forConsole.account.getPrefs(),
isCloud ? sdk.forConsole.billing.getPlansInfo() : null,
isCloud ? sdk.forConsole.billing.listPlans() : null,
fetch(`${endpoint}/health/version`, {
headers: { 'X-Appwrite-Project': project }
}).then((response) => response.json() as { version?: string }),
@@ -198,13 +198,16 @@
<Typography.Text>
For more details on our plans, visit our
<Link.Anchor
href="https://appwrite.io/pricing"
href="{resolvedProfile.website}/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
</Typography.Text>
<PlanSelection bind:billingPlan={selectedPlan} isNewOrg />
<PlanSelection
isNewOrg
disabled={$isSubmitting}
bind:billingPlan={selectedPlan} />
</Layout.Stack>
</Fieldset>
{#if !isFreePlan(selectedPlan)}
@@ -258,6 +261,8 @@
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
<Button
fullWidthMobile
forceShowLoader
submissionLoader={$isSubmitting}
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting}>
Create organization
@@ -51,7 +51,7 @@
will need to either upgrade to the Pro plan, transfer your projects to a Pro
organization, or migrate to self-hosting.
<svelte:fragment slot="actions">
<Button href="https://appwrite.io/pricing" external text>Learn more</Button>
<Button href="{resolvedProfile.website}/pricing" external text>Learn more</Button>
</svelte:fragment>
</Alert.Inline>
{/if}
@@ -37,13 +37,14 @@
import type { Models } from '@appwrite.io/console';
import { toLocaleDate } from '$lib/helpers/date';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import { isFreePlan } from '$lib/helpers/billing.js';
import { isFreePlan, isPaidPlan } from '$lib/helpers/billing.js';
export let data;
let selectedCoupon: Partial<Coupon> = null;
let selectedPlan: BillingPlan = data.plan as BillingPlan;
let previousPage: string = base;
let showExitModal = false;
let formComponent: Form;
@@ -98,8 +99,7 @@
await validate(organizationId, invites);
}
selectedPlan =
$currentPlan?.$id === BillingPlan.SCALE ? BillingPlan.SCALE : BillingPlan.PRO;
selectedPlan = $currentPlan?.$id as BillingPlan;
try {
orgUsage = await sdk.forConsole.billing.listUsage(data.organization.$id);
@@ -108,11 +108,10 @@
}
try {
allProjects = await sdk.forConsole.projects.list([
Query.equal('teamId', data.organization.$id),
Query.limit(1000)
]);
} catch {
allProjects = await sdk.forConsole.projects.list({
queries: [Query.limit(1000), Query.equal('teamId', data.organization.$id)]
});
} catch (error) {
allProjects = { projects: [] };
}
});
@@ -234,6 +233,7 @@
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
);
}
const org = await sdk.forConsole.billing.updatePlan(
data.organization.$id,
selectedPlan,
@@ -294,11 +294,17 @@
}
}
$: isUpgrade = $plansInfo.get(selectedPlan).order > $currentPlan?.order;
$: isDowngrade = $plansInfo.get(selectedPlan).order < $currentPlan?.order;
$: isUpgrade = $plansInfo.get(selectedPlan)?.order > $currentPlan?.order;
$: isDowngrade = $plansInfo.get(selectedPlan)?.order < $currentPlan?.order;
$: isButtonDisabled =
$organization?.billingPlan === selectedPlan ||
(isDowngrade && isFreePlan(selectedPlan) && data.hasFreeOrgs);
// reactive vars for same group downgrades
$: selectedPlanGroup = $plansInfo?.get(selectedPlan)?.group;
$: currentPlanGroup = $plansInfo?.get($organization?.billingPlan)?.group;
$: isSameGroupDowngrade =
currentPlanGroup && selectedPlanGroup && currentPlanGroup === selectedPlanGroup;
</script>
<svelte:head>
@@ -313,7 +319,7 @@
<Typography.Text>
For more details on our plans, visit our
<Link.Anchor
href="https://appwrite.io/pricing"
href="{resolvedProfile.website}/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
@@ -326,7 +332,10 @@
</Alert.Inline>
{/if}
<PlanSelection bind:billingPlan={selectedPlan} selfService={data.selfService} />
<PlanSelection
disabled={$isSubmitting}
selfService={data.selfService}
bind:billingPlan={selectedPlan} />
{#if isDowngrade && isFreePlan(selectedPlan) && data.hasFreeOrgs}
<Alert.Inline
@@ -335,10 +344,11 @@
To downgrade this organization, first migrate or delete your existing
free organization.
<Layout.Stack gap="xs" direction="row" justifyContent="flex-start">
<!-- check the link on imagine later -->
<Button
compact
external
href="https://appwrite.io/docs/advanced/migrations/cloud"
href="{resolvedProfile.website}/docs/advanced/migrations/cloud"
>Migration guide</Button>
</Layout.Stack>
</Alert.Inline>
@@ -350,15 +360,22 @@
extraMembers *
($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0)
)}
{#if selectedPlan === BillingPlan.PRO}
{#if isPaidPlan(selectedPlan)}
<Alert.Inline status="error">
<svelte:fragment slot="title">
Your monthly payments will be adjusted for the Pro plan
</svelte:fragment>
After switching plans,
<b
>you will be charged {price} monthly for {extraMembers} team members.</b>
This will be reflected in your next invoice.
{#if isSameGroupDowngrade}
After switching plans,
<b>you will be charged {price}.</b>
This will be reflected in your next invoice.
{:else}
After switching plans,
<b
>you will be charged {price} monthly for {extraMembers} team
members.</b>
This will be reflected in your next invoice.
{/if}
</Alert.Inline>
{:else if isFreePlan(selectedPlan)}
<Alert.Inline
@@ -374,12 +391,14 @@
</Alert.Inline>
{/if}
<OrganizationUsageLimits
bind:this={usageLimitsComponent}
organization={data.organization}
projects={allProjects?.projects || []}
members={data.members?.memberships || []}
storageUsage={orgUsage?.storageTotal ?? 0} />
{#if !isSameGroupDowngrade}
<OrganizationUsageLimits
bind:this={usageLimitsComponent}
organization={data.organization}
projects={allProjects?.projects || []}
members={data.members?.memberships || []}
storageUsage={orgUsage?.storageTotal ?? 0} />
{/if}
{/if}
</Layout.Stack>
</Fieldset>
@@ -3,17 +3,18 @@ import type { Organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { isFreePlan } from '$lib/helpers/billing';
import type { PlanList } from '$lib/sdk/billing';
export const load: PageLoad = async ({ depends, parent }) => {
const { members, currentPlan, organizations } = await parent();
depends(Dependencies.UPGRADE_PLAN);
let plans;
let plans: PlanList;
try {
plans = await sdk.forConsole.billing.listPlans();
} catch (error) {
console.error('Failed to load billing plans:', error);
plans = { plans: {} };
plans = { total: 0, plans: [] };
}
let plan: BillingPlan;
@@ -25,6 +25,7 @@
import { onMount } from 'svelte';
import type { UsageProjectInfo } from '../../store';
import { isFreePlan } from '$lib/helpers/billing';
import { resolvedProfile } from '$lib/profiles/index.svelte';
export let data;
@@ -83,7 +84,7 @@
<Link.Button on:click={() => ($showUsageRatesModal = true)}>Learn more</Link.Button>
{:else}
<Link.Anchor
href="https://appwrite.io/pricing"
href="{resolvedProfile.website}/pricing"
target="_blank"
rel="noopener noreferrer">
Learn more
@@ -98,7 +99,7 @@
<Link.Button on:click={() => ($showUsageRatesModal = true)}>Learn more</Link.Button>
{:else}
<Link.Anchor
href="https://appwrite.io/pricing"
href="{resolvedProfile.website}/pricing"
target="_blank"
rel="noopener noreferrer">
Learn more
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Card } from '$lib/components';
import { Card, CustomId } from '$lib/components';
import { Button, Form } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
@@ -11,8 +11,7 @@
import { Fieldset, Layout, Icon, Input, Tag } from '@appwrite.io/pink-svelte';
import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte';
import { onMount } from 'svelte';
import { ID, Runtime, Type } from '@appwrite.io/console';
import { CustomId } from '$lib/components';
import { ID, Runtime, TemplateReferenceType } from '@appwrite.io/console';
import { getIconFromRuntime } from '$lib/stores/runtimes';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
import { iconPath } from '$lib/stores/app';
@@ -32,8 +31,8 @@
let showCustomId = $state(false);
let isSubmitting = $state(writable(false));
let id = $state(ID.unique());
let name = $state(data.repository.name);
let id: string = $state(ID.unique());
let name: string = $state(data.repository.name);
let execute = $state(true);
let entrypoint = $state('');
@@ -135,7 +134,7 @@
repository: data.repository.name,
owner: data.repository.owner,
rootDirectory: rootDir || '.',
type: Type.Tag,
type: TemplateReferenceType.Tag,
reference: latestTag ?? '1.0.0',
activate: true
});
@@ -10,7 +10,7 @@
import { installation, repository } from '$lib/stores/vcs';
import { Layout } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
import { ID, Runtime, VCSDeploymentType, VCSDetectionType } from '@appwrite.io/console';
import { ID, Runtime, VCSDetectionType, VCSReferenceType } from '@appwrite.io/console';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
import Details from '../(components)/details.svelte';
@@ -133,7 +133,7 @@
.forProject(page.params.region, page.params.project)
.functions.createVcsDeployment({
functionId: func.$id,
type: VCSDeploymentType.Branch,
type: VCSReferenceType.Branch,
reference: branch,
activate: true
});
@@ -15,7 +15,7 @@
import { writable } from 'svelte/store';
import ProductionBranch from '$lib/components/git/productionBranchFieldset.svelte';
import Configuration from './configuration.svelte';
import { ID, Runtime, Type, type Models } from '@appwrite.io/console';
import { ID, Runtime, type Models, TemplateReferenceType } from '@appwrite.io/console';
import {
ConnectBehaviour,
NewRepository,
@@ -180,7 +180,7 @@
repository: data.template.providerRepositoryId || undefined,
owner: data.template.providerOwner || undefined,
rootDirectory: rt?.providerRootDirectory || undefined,
type: Type.Tag,
type: TemplateReferenceType.Tag,
reference: data.template.providerVersion || undefined,
activate: true
});
@@ -9,7 +9,7 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { installation, repository, sortBranches } from '$lib/stores/vcs';
import { Runtime, VCSDeploymentType, type Models } from '@appwrite.io/console';
import { Runtime, VCSReferenceType, type Models } from '@appwrite.io/console';
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
import { Icon, Input, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte';
import { func } from '../store';
@@ -98,7 +98,7 @@
.forProject(page.params.region, page.params.project)
.functions.createVcsDeployment({
functionId: $func.$id,
type: VCSDeploymentType.Commit,
type: VCSReferenceType.Commit,
reference: commit,
activate
});
@@ -107,7 +107,7 @@
.forProject(page.params.region, page.params.project)
.functions.createVcsDeployment({
functionId: $func.$id,
type: VCSDeploymentType.Branch,
type: VCSReferenceType.Branch,
reference: branch,
activate
});
@@ -565,7 +565,7 @@
color="--fgcolor-neutral-secondary">
Deploy the {resolvedProfile.platform} MCP server
with a single click, or view the <Link.Anchor
href="https://appwrite.io/docs"
href="{resolvedProfile.website}/docs"
target="_blank">docs</Link.Anchor> for instructions.
</Typography.Text>
</Layout.Stack>
@@ -598,7 +598,7 @@
</Card.Base>
<Card.Link
href="https://appwrite.io/discord"
href="{resolvedProfile.website}/discord"
padding="s"
on:click={() => {
trackEvent(Click.OnboardingDiscordClick);
@@ -212,7 +212,7 @@
<svelte:fragment slot="description">
Deploy the {resolvedProfile.platform} MCP server with a single click, or view the
<PinkLink.Anchor
href="https://appwrite.io/docs"
href="{resolvedProfile.website}/docs"
target="_blank"
rel="noreferrer">docs</PinkLink.Anchor> for instructions.
</svelte:fragment>
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Card } from '$lib/components';
import { Card, CustomId } from '$lib/components';
import { Button, Form } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
@@ -12,8 +12,13 @@
import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte';
import { onMount } from 'svelte';
import Domain from '../domain.svelte';
import { Adapter, BuildRuntime, Framework, ID, Type } from '@appwrite.io/console';
import { CustomId } from '$lib/components';
import {
Adapter,
BuildRuntime,
Framework,
ID,
TemplateReferenceType
} from '@appwrite.io/console';
import { getFrameworkIcon } from '$lib/stores/sites';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
import { iconPath } from '$lib/stores/app';
@@ -173,7 +178,7 @@
repository: data.repository.name,
owner: data.repository.owner,
rootDirectory: rootDir || '.',
type: Type.Tag,
type: TemplateReferenceType.Tag,
reference: latestTag ?? '1.0.0',
activate: true
});
@@ -19,7 +19,7 @@
BuildRuntime,
Framework,
ID,
VCSDeploymentType,
VCSReferenceType,
VCSDetectionType
} from '@appwrite.io/console';
import type { Models } from '@appwrite.io/console';
@@ -98,8 +98,8 @@
let site = await sdk.forProject(page.params.region, page.params.project).sites.create({
siteId: id || ID.unique(),
name,
framework: fr,
buildRuntime,
framework: fr as Framework,
buildRuntime: buildRuntime as BuildRuntime,
installCommand,
buildCommand,
outputDirectory,
@@ -132,7 +132,7 @@
.forProject(page.params.region, page.params.project)
.sites.createVcsDeployment({
siteId: site.$id,
type: VCSDeploymentType.Branch,
type: VCSReferenceType.Branch,
reference: branch,
activate: true
});
@@ -24,7 +24,14 @@
import Details from '../../details.svelte';
import Configuration from './configuration.svelte';
import Aside from '../../aside.svelte';
import { Adapter, BuildRuntime, Framework, ID, Type, type Models } from '@appwrite.io/console';
import {
Adapter,
BuildRuntime,
Framework,
ID,
type Models,
TemplateReferenceType
} from '@appwrite.io/console';
import {
ConnectBehaviour,
NewRepository,
@@ -120,8 +127,8 @@
.sites.create({
siteId: id || ID.unique(),
name,
framework: fr,
buildRuntime,
framework: fr as Framework,
buildRuntime: buildRuntime as BuildRuntime,
installCommand: framework.installCommand,
buildCommand: framework.buildCommand,
outputDirectory: framework.outputDirectory,
@@ -160,7 +167,7 @@
repository: data.template.providerRepositoryId,
owner: data.template.providerOwner,
rootDirectory: framework.providerRootDirectory,
type: Type.Tag,
type: TemplateReferenceType.Tag,
reference: data.template.providerVersion,
activate: true
});
@@ -14,7 +14,7 @@
Adapter,
BuildRuntime,
Framework,
VCSDeploymentType,
VCSReferenceType,
type Models
} from '@appwrite.io/console';
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
@@ -106,7 +106,7 @@
.forProject(page.params.region, page.params.project)
.sites.createVcsDeployment({
siteId: site.$id,
type: VCSDeploymentType.Commit,
type: VCSReferenceType.Commit,
reference: commit,
activate
});
@@ -115,7 +115,7 @@
.forProject(page.params.region, page.params.project)
.sites.createVcsDeployment({
siteId: site.$id,
type: VCSDeploymentType.Branch,
type: VCSReferenceType.Branch,
reference: branch,
activate
});
@@ -149,13 +149,13 @@
bind:value={pass} />
<InputChoice required bind:value={terms} id="terms" label="terms" showLabel={false}>
By registering, you agree that you have read, understand, and acknowledge our <Link.Anchor
href="https://appwrite.io/privacy"
href="{resolvedProfile.website}/privacy"
target="_blank"
rel="noopener noreferrer">
Privacy Policy</Link.Anchor>
and accept our
<Link.Anchor
href="https://appwrite.io/terms"
href="{resolvedProfile.website}/terms"
target="_blank"
rel="noopener noreferrer">General Terms of Use</Link.Anchor
>.</InputChoice>
@@ -134,14 +134,14 @@
<InputChoice required value={terms} id="terms" label="terms" showLabel={false}>
By registering, you agree that you have read, understand, and acknowledge our <a
class="link"
href="https://appwrite.io/privacy"
href="{resolvedProfile.website}/privacy"
target="_blank"
rel="noopener noreferrer">
Privacy Policy</a>
and accept our
<a
class="link"
href="https://appwrite.io/terms"
href="{resolvedProfile.website}/terms"
target="_blank"
rel="noopener noreferrer">General Terms of Use</a
>.</InputChoice>
+2 -2
View File
@@ -81,12 +81,12 @@
label="terms"
showLabel={false}>
By accepting the invitation, you agree to the <Link.Anchor
href="https://appwrite.io/terms"
href="{resolvedProfile.website}/terms"
target="_blank"
rel="noopener noreferrer">Terms and Conditions</Link.Anchor>
and
<Link.Anchor
href="https://appwrite.io/privacy"
href="{resolvedProfile.website}/privacy"
target="_blank"
rel="noopener noreferrer">
Privacy Policy</Link.Anchor