Merge branch 'feat-pink-v2' into mgn-dv

# Conflicts:
#	Dockerfile
#	package.json
#	pnpm-lock.yaml
#	src/lib/components/index.ts
#	src/routes/(console)/organization-[organization]/+page.svelte
#	src/routes/(console)/organization-[organization]/billing/+page.ts
#	src/routes/(console)/organization-[organization]/settings/deleteOrganizationModal.svelte
#	src/routes/(console)/project-[project]/messaging/+page.svelte
This commit is contained in:
ernstmul
2025-05-01 15:21:37 +02:00
227 changed files with 3459 additions and 1822 deletions
+1
View File
@@ -42,6 +42,7 @@ jobs:
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
"SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}"
"SENTRY_RELEASE=${{ github.event.release.tag_name }}"
publish-cloud-stage:
runs-on: ubuntu-latest
steps:
+4 -1
View File
@@ -6,6 +6,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm i -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.0.0 --activate
ADD ./package.json /app/package.json
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml
@@ -25,12 +26,14 @@ ARG PUBLIC_GROWTH_ENDPOINT
ARG PUBLIC_STRIPE_KEY
ARG SENTRY_AUTH_TOKEN
ARG PUBLIC_PROJECT_PROFILE
ARG SENTRY_RELEASE
ENV PUBLIC_APPWRITE_ENDPOINT=$PUBLIC_APPWRITE_ENDPOINT
ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV SENTRY_RELEASE=$SENTRY_RELEASE
ENV PUBLIC_PROJECT_PROFILE=$PUBLIC_PROJECT_PROFILE
ENV NODE_OPTIONS=--max_old_space_size=8192
@@ -41,4 +44,4 @@ FROM nginx:1.27-alpine
EXPOSE 80
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html/console
COPY --from=build /app/build /usr/share/nginx/html/console
+6 -5
View File
@@ -22,20 +22,21 @@
},
"dependencies": {
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@207628e",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@16d19d7",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@cbf05a1",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
"ai": "^2.2.37",
"analytics": "^0.8.16",
"@ai-sdk/svelte": "^1.1.22",
"cron-parser": "^4.9.0",
"dayjs": "^1.11.13",
"deep-equal": "^2.2.3",
"echarts": "^5.6.0",
"envfile": "^7.1.0",
"ignore": "^6.0.2",
"monaco-editor": "^0.52.2",
"nanoid": "^5.1.5",
@@ -50,7 +51,7 @@
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.5",
"@playwright/test": "^1.51.1",
@@ -86,7 +87,7 @@
"svelte-sequential-preprocessor": "^2.0.2",
"tslib": "^2.8.1",
"typescript": "^5.8.2",
"typescript-eslint": "^8.29.1",
"typescript-eslint": "^8.30.1",
"vite": "^6.2.3",
"vitest": "^3.0.0"
},
+1 -1
View File
@@ -13,7 +13,7 @@ const config: PlaywrightTestConfig = {
webServer: {
timeout: 120000,
env: {
PUBLIC_APPWRITE_ENDPOINT: 'https://console-testing-2.appwrite.org/v1',
PUBLIC_APPWRITE_ENDPOINT: 'https://stage.cloud.appwrite.io/v1',
PUBLIC_CONSOLE_MODE: 'cloud',
PUBLIC_STRIPE_KEY:
'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn'
+5
View File
@@ -6,6 +6,7 @@ import { user } from '$lib/stores/user';
import { ENV, MODE, VARS, isCloud } from '$lib/system';
import { AppwriteException } from '@appwrite.io/console';
import { browser } from '$app/environment';
import { getReferrerAndUtmSource } from '$lib/helpers/utm';
function plausible(domain: string): AnalyticsPlugin {
if (!browser) return { name: 'analytics-plugin-plausible' };
@@ -64,6 +65,8 @@ export function trackEvent(name: string, data: object = null): void {
};
}
data = { ...data, ...getReferrerAndUtmSource() };
if (ENV.DEV || ENV.PREVIEW) {
console.debug(`[Analytics] Event ${name} ${path}`, data);
} else {
@@ -138,6 +141,8 @@ export function isTrackingAllowed() {
}
export enum Click {
BackupCopyIdClick = 'click_backup_copy_id',
BackupDeleteClick = 'click_backup_delete',
BackupRestoreClick = 'click_backup_restore',
BreadcrumbClick = 'click_breadcrumb',
ConnectRepositoryClick = 'click_connect_repository',
+9 -2
View File
@@ -8,16 +8,23 @@
<script lang="ts">
import { Colors } from '$lib/charts/config';
import { Status } from '$lib/components';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { abbreviateNumber, formatNumberWithCommas } from '$lib/helpers/numbers';
export let legendData: LegendData[] = [];
export let decimalsForAbbreviate: number = 1;
export let numberFormat: 'comma' | 'abbreviate' = 'comma';
let colors = Object.values(Colors);
</script>
<div class="u-flex u-cross-center u-gap-16">
{#each legendData as { name, value }, index}
{@const formattedValue = typeof value === 'number' ? formatNumberWithCommas(value) : value}
{@const formattedValue =
typeof value === 'number'
? numberFormat === 'comma'
? formatNumberWithCommas(value)
: abbreviateNumber(value, decimalsForAbbreviate)
: value}
<Status status="none" statusIconStyle="background-color: {colors[index % colors.length]}">
{name} ({formattedValue})
</Status>
+4 -3
View File
@@ -7,7 +7,7 @@
import { AvatarInitials, Code, LoadingDots, SvgIcon } from '$lib/components';
import { user } from '$lib/stores/user';
import { useCompletion } from 'ai/svelte';
import { useCompletion } from '@ai-sdk/svelte';
import { subPanels } from '../subPanels';
import { isLanguage, type Language } from '$lib/components/code.svelte';
@@ -20,7 +20,8 @@
headers: {
'x-appwrite-project': 'console'
},
credentials: 'include'
credentials: 'include',
streamProtocol: 'text'
});
const examples = [
@@ -179,7 +180,7 @@
{#if $isLoading || answer}
<div class="content">
<div class="u-flex u-gap-8 u-cross-center">
<div class="avatar is-size-x-small">{getInitials($user.name)}</div>
<div class="avatar is-size-x-small">{getInitials($user.name || $user.email)}</div>
<p class="u-opacity-75">{previousQuestion}</p>
</div>
<div class="u-flex u-gap-8 u-margin-block-start-24">
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Click, trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { activeHeaderAlert } from '$routes/(console)/store';
@@ -29,7 +29,7 @@
secondary
fullWidthMobile
class="u-line-height-1"
href={`${base}/apply-credit?code=appw50&org=${$organization.$id}`}
href={`${base}/apply-credit?code=${NEW_DEV_PRO_UPGRADE_COUPON}&org=${$organization.$id}`}
on:click={() => {
trackEvent(Click.CreditsRedeemClick, {
from: 'button',
@@ -0,0 +1,25 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { failedInvoice } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
$: isOnProjects = $page.route.id.includes('project-[project]');
</script>
{#if $failedInvoice && $failedInvoice.teamId === $organization.$id && isOnProjects}
<HeaderAlert type="error" title="A scheduled payment for {$organization.name} failed">
To avoid service disruptions in your projects, please verify your payment details and try
again.
<svelte:fragment slot="buttons">
<Button
href={`${base}/organization-${$organization?.$id}/billing`}
secondary
fullWidthMobile>
<span class="text">Go to billing</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
+12 -9
View File
@@ -3,6 +3,7 @@
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { Layout } from '@appwrite.io/pink-svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@@ -17,7 +18,7 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
dispatch('validation', couponData);
coupon = null;
@@ -36,17 +37,19 @@
}
</script>
<InputText
placeholder="Coupon code"
id="code"
label="Add credits"
{required}
disabled={couponData?.status === 'active'}
bind:value={coupon}>
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center">
<InputText
placeholder="Coupon code"
id="code"
label="Add credits"
{required}
disabled={couponData?.status === 'active'}
bind:value={coupon}>
</InputText>
<Button secondary disabled={couponData?.status === 'active' || !coupon} on:click={addCoupon}>
Apply
</Button>
</InputText>
</Layout.Stack>
{#if couponData?.status === 'error'}
<div>
<span class="icon-exclamation-circle u-color-text-danger"></span>
@@ -0,0 +1,49 @@
<script lang="ts">
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { IconX } from '@appwrite.io/pink-icons-svelte';
import { Icon } from '@appwrite.io/pink-svelte';
export let label: string;
export let value: number;
export let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
export let fixedCoupon = false;
</script>
{#if value > 0}
<span class="u-flex u-main-space-between">
<div class="u-flex u-cross-center u-gap-4">
<p class="text">
<span class="icon-tag u-color-text-success" aria-hidden="true"></span>
<span>
{label}
</span>
</p>
{#if !fixedCoupon && label.toLowerCase() === 'credits'}
<button
type="button"
class="button is-text is-only-icon"
style="--button-size:1.5rem;"
aria-label="Close"
title="Close"
on:click={() =>
(couponData = {
code: null,
status: null,
credits: null
})}>
<Icon icon={IconX} />
</button>
{/if}
</div>
{#if value >= 100}
<p class="inline-tag">Credits applied</p>
{:else}
<span class="u-color-text-success">-{formatCurrency(value)}</span>
{/if}
</span>
{/if}
@@ -0,0 +1,144 @@
<script lang="ts">
import { InputChoice, InputNumber } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon, Estimation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { AppwriteException } from '@appwrite.io/console';
import Card from '../card.svelte';
import DiscountsApplied from './discountsApplied.svelte';
import { addNotification } from '$lib/stores/notifications';
export let organizationId: string | undefined = undefined;
export let billingPlan: string;
export let collaborators: string[];
export let fixedCoupon = false;
export let couponData: Partial<Coupon>;
export let billingBudget: number;
let budgetEnabled = false;
let estimation: Estimation;
async function getEstimate(
billingPlan: string,
collaborators: string[],
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationCreateOrganization(
billingPlan,
couponId === '' ? null : couponId,
collaborators ?? []
);
} catch (e) {
if (e instanceof AppwriteException) {
if (
e.type === 'billing_coupon_not_found' ||
e.type === 'billing_coupon_already_used' ||
e.type === 'billing_credit_unsupported'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
addNotification({
type: 'error',
isHtml: false,
message: e.message
});
}
}
async function getUpdatePlanEstimate(
organizationId: string,
billingPlan: string,
collaborators: string[],
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationUpdatePlan(
organizationId,
billingPlan,
couponId && couponId.length > 0 ? couponId : null,
collaborators ?? []
);
} catch (e) {
if (e instanceof AppwriteException) {
if (
e.type === 'billing_coupon_not_found' ||
e.type === 'billing_coupon_already_used' ||
e.type === 'billing_credit_unsupported'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
addNotification({
type: 'error',
isHtml: false,
message: e.message
});
}
}
$: organizationId
? getUpdatePlanEstimate(organizationId, billingPlan, collaborators, couponData?.code)
: getEstimate(billingPlan, collaborators, couponData?.code);
</script>
{#if estimation}
<Card class="u-flex u-flex-vertical u-gap-8">
<slot />
{#if estimation}
{#each estimation.items ?? [] as item}
{#if item.value > 0}
<span class="u-flex u-main-space-between">
<p class="text">{item.label}</p>
<p class="text">{formatCurrency(item.value)}</p>
</span>
{/if}
{/each}
{#each estimation.discounts ?? [] as item}
<DiscountsApplied {fixedCoupon} bind:couponData {...item} />
{/each}
<div class="u-sep-block-start"></div>
<span class="u-flex u-main-space-between">
<p class="text">Total due</p>
<p class="text">
{formatCurrency(estimation.grossAmount)}
</p>
</span>
<p class="text u-margin-block-start-16">
You'll pay <span class="u-bold">{formatCurrency(estimation.grossAmount)}</span> now.
{#if couponData?.code}Once your credits run out,{:else}Then{/if} you'll be charged
<span class="u-bold">{formatCurrency(estimation.amount)}</span> every 30 days.
</p>
{/if}
<InputChoice
type="switchbox"
id="budget"
label="Enable budget cap"
tooltip="If enabled, you will be notified when your spending reaches 75% of the set cap. Update cap alerts in your organization settings."
fullWidth
bind:value={budgetEnabled}>
{#if budgetEnabled}
<div class="u-margin-block-start-16">
<InputNumber
id="budget"
label="Budget cap (USD)"
placeholder="0"
min={0}
bind:value={billingBudget} />
</div>
{/if}
</InputChoice>
</Card>
{/if}
@@ -43,7 +43,7 @@
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>Additional seats ({collaborators?.length})</Typography.Text>
>Additional seats ({collaborators?.length ?? 0})</Typography.Text>
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{formatCurrency(extraSeatsCost)}</Typography.Text>
</Layout.Stack>
+2 -1
View File
@@ -2,8 +2,9 @@ export { default as PaymentBoxes } from './paymentBoxes.svelte';
export { default as CouponInput } from './couponInput.svelte';
export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
export { default as UsageRates } from './usageRates.svelte';
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
export { default as CreditsApplied } from './creditsApplied.svelte';
export { default as PlanSelection } from './planSelection.svelte';
export { default as EstimatedTotal } from './estimatedTotal.svelte';
export { default as SelectPlan } from './selectPlan.svelte';
+14 -23
View File
@@ -2,19 +2,17 @@
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { Button } from '$lib/elements/forms';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import type { OrganizationUsage } from '$lib/sdk/billing';
import type { Aggregation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
export let tier: Tier;
export let members: number;
const plan = $plansInfo?.get(tier);
let excess: {
@@ -24,16 +22,15 @@
executions?: number;
members?: number;
} = null;
let usage: OrganizationUsage = null;
let aggregation: Aggregation = null;
let showExcess = false;
onMount(async () => {
usage = await sdk.forConsole.billing.listUsage(
aggregation = await sdk.forConsole.billing.getAggregation(
$organization.$id,
$organization.billingCurrentInvoiceDate,
new Date().toISOString()
$organization.billingAggregationId
);
excess = calculateExcess(usage, plan, members);
excess = calculateExcess(aggregation, plan);
showExcess = Object.values(excess).some((value) => value > 0);
});
</script>
@@ -41,22 +38,16 @@
{#if showExcess}
<Alert.Inline
status="error"
title={`Your ${tierToPlan($organization.billingPlan).name} plan subscription will end on ${toLocaleDate(
title={` Your organization will switch to ${tierToPlan(BillingPlan.FREE).name} plan on ${toLocaleDate(
$organization.billingNextInvoiceDate
)}`}>
Following payment of your final invoice, your organization will switch to the {tierToPlan(
BillingPlan.FREE
).name} plan. {#if excess?.members > 0}All team members except the owner will be removed on
that date.{/if} Service disruptions may occur unless resource usage is reduced.
<!-- Any executions, bandwidth, or messaging usage will be reset at that time. -->
<svelte:fragment slot="buttons">
<Button
text
external
href="https://appwrite.io/docs/advanced/platform/free#reaching-resource-limits">
Learn more
</Button>
</svelte:fragment>
You will retain access to {tierToPlan($organization.billingPlan).name} plan features until your
billing period ends. After that,
{#if excess?.members > 0}<span class="u-bold">
all team members except the owner will be removed,</span>
{:else}
<span class="u-bold">your organization will be limited to Free plan resources,</span>
{/if} and service disruptions may occur if usage exceeds Free plan limits.
</Alert.Inline>
<Table.Root columns={3} let:root>
@@ -73,7 +64,7 @@
{#if excess?.members}
<Table.Row.Base {root}>
<Table.Cell {root}>Organization members</Table.Cell>
<Table.Cell {root}>{plan.members} members</Table.Cell>
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up"></span>
@@ -57,12 +57,13 @@
data: [method.brand]
};
})} />
<div>
<Layout.Stack direction="row" alignItems="center">
<Button on:click={() => (showPaymentModal = true)} compact size="s">
<Icon icon={IconPlus} slot="start" size="s" />
Add payment method
</Button>
</div>
<slot name="actions" />
</Layout.Stack>
{:else}
<Card.Base variant="secondary" radius="s" padding="xs">
<Layout.Stack direction="row">
@@ -79,6 +80,7 @@
Add
</Button>
</Layout.Stack>
<slot name="actions" />
</Card.Base>
{/if}
</Layout.Stack>
@@ -0,0 +1,51 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { LabelCard } from '..';
export let billingPlan: string;
export let anyOrgFree = false;
export let isNewOrg = false;
let classes: string = '';
export { classes as class };
</script>
{#if billingPlan}
<ul class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8 {classes}">
{#each $plansInfo.values() as plan}
<li>
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={(plan.$id === BillingPlan.FREE && anyOrgFree) || !plan.selfService}
value={plan.$id}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
tooltipText={plan.$id === BillingPlan.FREE
? 'You are limited to 1 Free organization per account.'
: ''}
padding={1.5}>
<svelte:fragment slot="custom" let:disabled>
<div
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
class:u-opacity-50={disabled}>
<h4 class="body-text-2 u-bold">
{plan.name}
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{plan.desc}
</p>
<p>
{formatCurrency(plan?.price ?? 0)}
</p>
</div>
</svelte:fragment>
</LabelCard>
</li>
{/each}
</ul>
{/if}
+9 -2
View File
@@ -43,6 +43,13 @@
];
$: isFree = org.billingPlan === BillingPlan.FREE;
// equal or above means unlimited!
$: getCorrectSeatsCountValue = (count: number): string | number => {
// php int max is always larger than js
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
return exceedsSafeLimit ? 'Unlimited' : count || 0;
};
</script>
<Modal bind:show title="Usage rates">
@@ -75,10 +82,10 @@
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{usage.resource}</Table.Cell>
<Table.Cell column="limit" {root}>
{plan[usage.id] || 'Unlimited'}
{getCorrectSeatsCountValue(plan.addons.seats.limit)}
</Table.Cell>
<Table.Cell column="rate" {root}>
{formatCurrency(plan.addons?.member?.price)}/{usage?.unit}
{formatCurrency(plan.addons?.seats?.price)}/{usage?.unit}
</Table.Cell>
</Table.Row.Base>
{:else}
@@ -19,7 +19,8 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
// const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon); //TODO: double check that this is the correct method
if (response.onlyNewOrgs && !isNewOrg) {
show = false;
@@ -37,6 +38,14 @@
message: 'Credits applied successfully'
});
}
couponData = response;
dispatch('validation', couponData);
coupon = null;
show = false;
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
} catch (e) {
error = e.message;
}
+1 -1
View File
@@ -4,7 +4,7 @@
</script>
<Box>
<Layout.Stack direction="row" justifyContent="flex-start">
<Layout.Stack direction="row" justifyContent="flex-start" alignItems="center">
<slot name="image" />
<div>
<slot name="title" />
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { page } from '$app/state';
import type { Writable } from 'svelte/store';
import { preferences } from '$lib/stores/preferences';
import { onMount, type Snippet } from 'svelte';
import type { Column } from '$lib/helpers/types';
import { ActionMenu, Layout, Popover, Selector } from '@appwrite.io/pink-svelte';
let {
columns,
isCustomCollection = false,
allowNoColumns = false,
children
}: {
columns: Writable<Column[]>;
isCustomCollection?: boolean;
allowNoColumns?: boolean;
children: Snippet<[toggle: () => void, selectedColumnsNumber: number]>;
} = $props();
onMount(async () => {
if (isCustomCollection) {
const prefs = preferences.getCustomCollectionColumns(page.params.collection);
columns.set(
$columns.map((column) => {
column.hide = prefs?.includes(column.id) ?? false;
return column;
})
);
} else {
const prefs = preferences.get(page.route);
// Override the shown columns only if a preference was set
if (prefs?.columns) {
columns.set(
$columns.map((column) => {
column.hide = prefs.columns?.includes(column.id) ?? false;
return column;
})
);
}
}
columns.subscribe((ctx) => {
const columns = ctx.filter((n) => n.hide === true).map((n) => n.id);
if (isCustomCollection) {
preferences.setCustomCollectionColumns(columns);
} else {
preferences.setColumns(columns);
}
});
});
let selectedColumnsNumber = $derived(
$columns.reduce((acc, column) => {
if (column.hide === true) return acc;
return ++acc;
}, 0)
);
</script>
{#if $columns?.length}
<Popover let:toggle placement="bottom-end" padding="none">
{@render children(toggle, selectedColumnsNumber)}
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
{#each $columns as column}
{#if !column?.exclude}
<ActionMenu.Item.Button
on:click={() => (column.hide = !column.hide)}
disabled={allowNoColumns
? false
: selectedColumnsNumber <= 1 && column.hide !== true}>
<Layout.Stack direction="row" gap="s">
<Selector.Checkbox
checked={!column.hide}
size="s"
on:click={() => (column.hide = !column.hide)} />
{column.title}
</Layout.Stack>
</ActionMenu.Item.Button>
{/if}
{/each}
</ActionMenu.Root>
</svelte:fragment>
</Popover>
{/if}
+1 -2
View File
@@ -54,8 +54,7 @@
<InteractiveText
variant="copy"
isVisible
text={$consoleVariables._APP_DOMAIN_TARGET_CNAME}>
{$consoleVariables._APP_DOMAIN_TARGET_CNAME}</InteractiveText>
text={$consoleVariables._APP_DOMAIN_TARGET_CNAME} />
</Table.Cell>
</Table.Row.Base>
</Table.Root>
@@ -37,8 +37,7 @@
<Table.Row.Base {root}>
<Table.Cell {root}>NS</Table.Cell>
<Table.Cell {root}>
<InteractiveText variant="copy" isVisible text={nameserver}>
{nameserver}</InteractiveText>
<InteractiveText variant="copy" isVisible text={nameserver} />
</Table.Cell>
</Table.Row.Base>
{/each}
@@ -63,8 +63,7 @@
<Table.Cell {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell {root}>
<InteractiveText variant="copy" isVisible text={setTarget()}>
{setTarget()}</InteractiveText>
<InteractiveText variant="copy" isVisible text={setTarget()} />
</Table.Cell>
</Table.Row.Base>
</Table.Root>
+1 -1
View File
@@ -69,7 +69,7 @@
if (!isMouseOverTooltip) {
hideTooltip();
}
}, 50);
}, 150);
}
$: timeToString = time ? timeDifference(time) : 'Invalid time';
@@ -7,13 +7,10 @@
export let noAspectRatio = false;
</script>
<Card.Base variant="secondary" padding="s">
<Layout.Stack direction="row">
<Card.Base variant="secondary" padding="s" radius="s">
<Layout.Stack direction="row" gap="l">
{#if $$slots?.image}
<div
style:--p-file-preview-border-color="transparent"
class="file-preview is-full-cover-image u-width-full-line u-height-100-percent"
style={noAspectRatio ? 'aspect-ratio: auto' : ''}>
<div style="flex-shrink:0">
<slot name="image" />
</div>
{/if}
+1 -1
View File
@@ -26,7 +26,7 @@
alignItems="center"
wrap="wrap">
<p class="text">Total results: 0</p>
<PaginationInline limit={1} offset={0} sum={0} {hidePages} />
<PaginationInline limit={1} offset={0} total={0} {hidePages} />
</Layout.Stack>
{/if}
</Layout.Stack>
+6 -2
View File
@@ -11,6 +11,7 @@
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/state';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '$routes/(console)/project-[project]/store';
$: $selectedFeedback = feedbackOptions.find((option) => option.type === $feedback.type);
@@ -24,8 +25,11 @@
page.url.href,
$user.name,
$user.email,
$organization.billingPlan,
$feedbackData.value
$organization?.billingPlan,
$feedbackData.value,
$organization?.$id,
$project?.$id,
$user.$id
);
addNotification({
type: 'success',
@@ -0,0 +1,118 @@
<script lang="ts">
import { capitalize } from '$lib/helpers/string';
import { IconChevronLeft, IconChevronRight } from '@appwrite.io/pink-icons-svelte';
import { BottomSheet } from '..';
import { addFilterAndApply, type FilterData } from './quickFilters';
import type { Writable } from 'svelte/store';
import type { Column } from '$lib/helpers/types';
let {
openBottomSheet = $bindable(false),
columns,
filterCols = $bindable([]),
analyticsSource
}: {
openBottomSheet: boolean;
columns: Writable<Column[]>;
filterCols: FilterData[];
analyticsSource?: string;
} = $props();
let subSheets = $derived(
filterCols.map((col) => {
return {
title: col.title,
top: {
title: col.title,
trailingIcon: IconChevronRight,
items: col.options.map((o) => {
return {
title: capitalize(o.label),
name: capitalize(o.label),
options: col.options,
checked: o.checked,
onClick: () => {
addFilterAndApply(
col.id,
col.title,
col.operator,
o.value,
generateFilterArrayValue(col, o.value),
$columns,
analyticsSource ?? ''
);
}
};
})
},
bottom: {
name: 'Back',
items: [
{
name: 'Back',
leadingIcon: IconChevronLeft,
navigatePrevious: true
}
]
}
};
})
);
let filtersBottomSheet = $derived({
top: {
title: 'Filters',
items: filterCols.map((col) => {
return {
name: col.title,
onClick: () =>
console.log(subSheets.find((sheet) => sheet?.title === col?.title)),
subMenu: subSheets.find((sheet) => sheet?.title === col?.title),
trailingIcon: IconChevronRight
};
})
},
bottom: {
name: 'Clear All',
items: [
{
name: 'Clear All',
onClick: () => {
filterCols.forEach((col) => {
addFilterAndApply(
col.id,
col.title,
col.operator,
null,
[],
$columns,
analyticsSource ?? ''
);
});
}
}
]
}
});
function generateFilterArrayValue(col: FilterData, value: string) {
if (!col?.array) return [];
if (col.options?.find((opt) => opt.value === value)?.checked) {
return col.options
?.filter((opt) => opt?.checked)
.map((opt) => opt.value)
.filter((item) => item !== value);
} else {
let arrayValue =
col.options?.filter((opt) => opt?.checked)?.map((opt) => opt.value) ?? [];
arrayValue = [...arrayValue, value];
return arrayValue;
}
}
$inspect(subSheets);
</script>
<BottomSheet.Menu bind:isOpen={openBottomSheet} menu={filtersBottomSheet} />
+1
View File
@@ -4,3 +4,4 @@ export { default as CustomFilters } from './customFilters.svelte';
export { default as QuickFilters } from './quickFilters.svelte';
export { default as ParsedTagList } from './parsedTagList.svelte';
export { hasPageQueries, queryParamToMap, queries } from '$lib/components/filters/store';
export { default as FiltersBottomSheet } from './filtersBottomSheet.svelte';
@@ -8,7 +8,7 @@
{#if $parsedTags?.length}
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline>
{#each $parsedTags as tag}
{#each $parsedTags as tag (tag.tag)}
<span>
<Tooltip
disabled={Array.isArray(tag.value) ? tag.value?.length < 3 : true}
+7 -107
View File
@@ -3,26 +3,21 @@
import { queryParamToMap } from '$lib/components/filters/store';
import type { Column } from '$lib/helpers/types';
import { type Writable } from 'svelte/store';
import { CustomFilters } from '$lib/components/filters';
import { addFilterAndApply, buildFilterCol, type FilterData } from './quickFilters';
import { CustomFilters, FiltersBottomSheet } from '$lib/components/filters';
import { addFilterAndApply, buildFilterCol } from './quickFilters';
import { parsedTags, setFilters } from './setFilters';
import Menu from '../menu/menu.svelte';
import { Button } from '$lib/elements/forms';
import { Icon } from '@appwrite.io/pink-svelte';
import {
IconChevronLeft,
IconChevronRight,
IconFilterLine
} from '@appwrite.io/pink-icons-svelte';
import { IconFilterLine } from '@appwrite.io/pink-icons-svelte';
import QuickfiltersSubMenu from './quickfiltersSubMenu.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { BottomSheet } from '..';
import { capitalize } from '$lib/helpers/string';
export let columns: Writable<Column[]>;
export let analyticsSource: string;
export let openBottomSheet = false;
let openBottomSheet = false;
//TODO: remove this when all header are replace with `ResponsiveContainerHeader`
let filterCols = $columns
.map((col) => (col.filter !== false ? buildFilterCol(col) : null))
.filter((f) => f?.options);
@@ -35,101 +30,6 @@
setFilters(localTags, filterCols, $columns);
filterCols = filterCols;
});
$: subSheets = filterCols.map((col) => {
return {
title: col.title,
top: {
title: col.title,
trailingIcon: IconChevronRight,
items: col.options.map((o) => {
return {
title: capitalize(o.label),
name: capitalize(o.label),
options: col.options,
checked: o.checked,
onClick: () => {
addFilterAndApply(
col.id,
col.title,
col.operator,
o.value,
generateFilterArrayValue(col, o.value),
$columns,
analyticsSource
);
}
};
})
},
bottom: {
name: 'Back',
items: [
{
name: 'Back',
leadingIcon: IconChevronLeft,
navigatePrevious: true,
onClick: () => {
// navigate to the previous menu
}
}
]
}
};
});
$: organizationsBottomSheet = {
top: {
title: 'Filters',
items: filterCols.map((col) => {
return {
name: col.title,
onClick: () =>
console.log(subSheets.find((sheet) => sheet?.title === col?.title)),
subMenu: subSheets.find((sheet) => sheet?.title === col?.title),
trailingIcon: IconChevronRight
};
})
},
bottom: {
name: 'Clear All',
items: [
{
name: 'Clear All',
onClick: () => {
filterCols.forEach((col) => {
addFilterAndApply(
col.id,
col.title,
col.operator,
null,
[],
$columns,
analyticsSource
);
});
}
}
]
}
};
function generateFilterArrayValue(col: FilterData, value: string) {
if (!col?.array) return [];
if (col.options?.find((opt) => opt.value === value)?.checked) {
return col.options
?.filter((opt) => opt?.checked)
.map((opt) => opt.value)
.filter((item) => item !== value);
} else {
let arrayValue =
col.options?.filter((opt) => opt?.checked)?.map((opt) => opt.value) ?? [];
arrayValue = [...arrayValue, value];
return arrayValue;
}
}
</script>
{#if $isSmallViewport}
@@ -161,7 +61,7 @@
</Button>
{/if}
<svelte:fragment slot="menu">
{#each filterCols as filter}
{#each filterCols as filter (filter.title + filter.id)}
{#if filter.options}
<QuickfiltersSubMenu
{filter}
@@ -203,5 +103,5 @@
{/if}
{#if $isSmallViewport && openBottomSheet}
<BottomSheet.Menu bind:isOpen={openBottomSheet} menu={organizationsBottomSheet} />
<FiltersBottomSheet bind:openBottomSheet {columns} {analyticsSource} bind:filterCols />
{/if}
+1 -1
View File
@@ -25,7 +25,7 @@ export function buildFilterCol(col: Column, customOperator = null): FilterData {
return {
value: (element?.value ?? element) as string,
label: (element?.label ?? element) as string,
checked: false
checked: undefined
};
})
};
@@ -43,7 +43,7 @@
<div class="menu subMenu" use:melt={$subMenu}>
<Card.Base padding="none">
<div use:melt={$radioGroup}>
{#each filter.options as option (option.value + option.checked)}
{#each filter.options as option (filter.title + option.value + option.label + option.checked)}
{#if variant === 'radio'}
<div use:melt={$item}>
<ActionMenu.Root>
+4 -2
View File
@@ -79,7 +79,6 @@
selectedRepository = $repositories.repositories[0].id;
$repository = $repositories.repositories[0];
}
return $repositories.repositories;
}
@@ -173,7 +172,10 @@
{:then response}
{#if response?.length}
<Paginator items={response} hideFooter={response?.length <= 6} limit={6}>
{#snippet children(paginatedItems)}
{#snippet children(
paginatedItems: Models.ProviderRepositoryRuntime[] &
Models.ProviderRepositoryFramework[]
)}
<Table.Root columns={1} let:root>
{#each paginatedItems as repo}
<Table.Row.Base {root}>
+71 -43
View File
@@ -4,9 +4,10 @@
import { iconPath } from '$lib/stores/app';
import { sdk } from '$lib/stores/sdk';
import { installation, repository } from '$lib/stores/vcs';
import { VCSDetectionType } from '@appwrite.io/console';
import { VCSDetectionType, type Models } from '@appwrite.io/console';
import { DirectoryPicker } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
type Directory = {
title: string;
@@ -14,6 +15,7 @@
fileCount: number;
thumbnailUrl: string;
children?: Directory[];
loading?: boolean;
};
export let show = false;
@@ -27,10 +29,13 @@
fullPath: './',
fileCount: 0,
thumbnailUrl: 'root',
children: []
children: [],
loading: false
}
];
let currentPath: string = './';
let currentDir: Directory;
export let expanded = writable(['lib-0', 'tree-0']);
onMount(async () => {
try {
@@ -47,8 +52,10 @@
title: dir.name,
fullPath: currentPath + dir.name,
fileCount: undefined,
thumbnailUrl: dir.name
thumbnailUrl: dir.name,
loading: false
}));
currentDir = directories[0];
// console.log(directories);
isLoading = false;
} catch (e) {
@@ -57,47 +64,70 @@
});
async function fetchContents(e: CustomEvent) {
const path = e.detail.fullPath;
const path = e.detail.fullPath as string;
currentPath = path;
try {
const content = await sdk.forProject.vcs.getRepositoryContents(
$installation.$id,
$repository.id,
path
);
// console.log(content);
const fileCount = content.contents?.length ?? 0;
const contentDirectories = content.contents.filter((e) => e.isDirectory);
if (contentDirectories.length === 0) {
return;
}
let currentDir = directories[0];
for (let i = 1; i < path.split('/').length; i++) {
const dir = path.split('/')[i];
currentDir = currentDir.children.find((d) => d.title === dir);
}
const pathSegments = path.split('/').filter((segment) => segment !== '.' && segment !== '');
let traversedDir = directories[0]; // Start at root
currentDir.fileCount = fileCount;
currentDir.children = contentDirectories.map((dir) => ({
title: dir.name,
fullPath: path + '/' + dir.name,
fileCount: undefined,
thumbnailUrl: undefined
}));
const runtime = await sdk.forProject.vcs.createRepositoryDetection(
$installation.$id,
$repository.id,
product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
path
);
//TODO: Fix runtime after passing type: runtime.framework || runtime.runtime
currentDir.children.forEach((dir) => {
dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
});
for (const segment of pathSegments) {
const nextDir = traversedDir.children?.find((dir) => dir.title === segment);
if (!nextDir) break;
traversedDir = nextDir;
}
currentDir = traversedDir;
if (!currentDir.fileCount) {
currentDir.loading = true;
directories = [...directories];
} catch (error) {
console.error(error);
try {
const content = await sdk.forProject.vcs.getRepositoryContents(
$installation.$id,
$repository.id,
path
);
// console.log(content);
const fileCount = content.contents?.length ?? 0;
const contentDirectories = content.contents.filter((e) => e.isDirectory);
if (contentDirectories.length === 0) {
return;
}
currentDir.fileCount = fileCount;
currentDir.children = contentDirectories.map((dir) => ({
title: dir.name,
fullPath: path + '/' + dir.name,
fileCount: undefined,
thumbnailUrl: undefined
}));
const runtime = await sdk.forProject.vcs.createRepositoryDetection(
$installation.$id,
$repository.id,
product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
path
);
if (product === 'sites') {
currentDir.children.forEach((dir) => {
dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
});
} else if (product === 'functions') {
currentDir.children.forEach((dir) => {
dir.thumbnailUrl = $iconPath(
(runtime as unknown as Models.DetectionRuntime).runtime,
'color'
);
});
}
directories = [...directories];
$expanded = [...$expanded, path];
} catch (error) {
console.error(error);
} finally {
currentDir.loading = false;
}
}
}
@@ -105,15 +135,13 @@
rootDir = currentPath;
show = false;
}
// $: console.log(rootDir);
</script>
<Modal title="Root directory" bind:show onSubmit={handleSubmit}>
<span slot="description">
Select the directory where your site code is located using the menu below.
</span>
<DirectoryPicker {directories} {isLoading} on:select={fetchContents} />
<DirectoryPicker {directories} {isLoading} on:select={fetchContents} bind:expanded />
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Cancel</Button>
+1
View File
@@ -81,4 +81,5 @@ export { default as Sidebar } from './sidebar.svelte';
export { default as BottomSheet } from './bottom-sheet/index';
export { default as Confirm } from './confirm.svelte';
export { default as UsageCard } from './usageCard.svelte';
export { default as ViewToggle } from './viewToggle.svelte';
export { default as Terminal } from './studio/terminal.svelte';
+3 -1
View File
@@ -79,7 +79,9 @@
on:cancel|preventDefault
{style}>
{#if show}
<slot close={closeModal} />
<div class="content">
<slot close={closeModal} />
</div>
{/if}
</dialog>
+1 -1
View File
@@ -20,4 +20,4 @@
}
</script>
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} />
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} type="link" />
+20 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
<script lang="ts" generics="T">
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import PaginationInline from './paginationInline.svelte';
import Limit from './limit.svelte';
import type { Snippet } from 'svelte';
let {
items = [],
@@ -10,7 +11,19 @@
hidePages = true,
hasLimit = false,
name = 'items',
gap = 's',
children
}: {
items: T[];
limit?: number;
hideFooter?: boolean;
hidePages?: boolean;
hasLimit?: boolean;
name?: string;
gap?:
| ('none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl')
| undefined;
children: Snippet<[T[], number]>;
} = $props();
let total = $derived(items.length);
@@ -20,11 +33,15 @@
let paginatedItems = $derived(items.slice(offset, offset + limit));
</script>
<Layout.Stack gap="s">
<Layout.Stack {gap}>
{@render children(paginatedItems, limit)}
{#if !hideFooter}
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<Layout.Stack
direction="row"
justifyContent="space-between"
alignItems="center"
wrap="wrap">
{#if hasLimit}
<Limit bind:limit sum={total} {name} />
{:else}
@@ -18,7 +18,7 @@
import Row from './row.svelte';
import { Icon, Selector, Table } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import type { Column } from '@appwrite.io/pink-svelte/dist/table';
import type { PinkColumn } from '$lib/helpers/types';
export let withCreate = false;
export let permissions: string[] = [];
@@ -126,7 +126,7 @@
return a.localeCompare(b);
}
const columns: Column[] = [
const columns: PinkColumn[] = [
{ id: 'role', width: { min: 100 } },
{ id: 'create', width: { min: 100 }, hide: !withCreate },
{ id: 'read', width: { min: 100 } },
@@ -226,3 +226,14 @@
</div>
</article>
{/if}
<style lang="scss">
.table-wrapper {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
</style>
+1 -1
View File
@@ -107,7 +107,7 @@
</Table.Root>
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {results?.total}</p>
<PaginationInline limit={5} bind:offset sum={results?.total} hidePages />
<PaginationInline limit={5} bind:offset total={results?.total} hidePages />
</div>
{:else if search}
<EmptySearch bind:search target="teams" hidePages>
+1 -1
View File
@@ -141,7 +141,7 @@
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<p class="text">Total results: {results?.total}</p>
<PaginationInline limit={5} bind:offset sum={results?.total} hidePages />
<PaginationInline limit={5} bind:offset total={results?.total} hidePages />
</Layout.Stack>
{:else if search}
<EmptySearch bind:search target="users" hidePages>
+13 -9
View File
@@ -7,7 +7,7 @@
export let maxValue: string | undefined = undefined;
export let maxUnit: string | undefined = undefined;
export let progressValue: number;
export let progressMax: number;
export let progressMax: number | undefined = undefined;
export let showBar = true;
export let progressBarData: Array<ProgressbarData> = [];
@@ -15,7 +15,7 @@
</script>
<section class="progress-bar">
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined && maxValue !== undefined}
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined}
<div class="u-flex u-flex-vertical">
<Layout.Stack direction="row" alignItems="center">
<Layout.Stack gap="s" direction="row" alignItems="baseline">
@@ -23,14 +23,18 @@
{currentValue}
</Typography.Title>
<Typography.Text>{currentUnit}</Typography.Text>
<Typography.Text color="--fgcolor-neutral-tertiary">
{maxValue}
{maxUnit ? maxUnit : ''}
</Typography.Text>
{#if maxValue !== undefined}
<Typography.Text color="--fgcolor-neutral-tertiary">
{maxValue}
{maxUnit ? maxUnit : ''}
</Typography.Text>
{/if}
</Layout.Stack>
<div>
<Badge variant="secondary" size="xs" content={`${progress}%`} />
</div>
{#if progressMax !== undefined}
<div>
<Badge variant="secondary" size="xs" content={`${progress}%`} />
</div>
{/if}
</Layout.Stack>
</div>
{/if}
+12
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
export let name: string;
export let group: string;
export let value: string | number | boolean;
export let disabled = false;
export let padding = 1;
export let autofocus = false;
export let fullHeight = true;
export let borderRadius: 'xsmall' | 'small' | 'medium' | 'large' = 'small';
@@ -13,9 +16,18 @@
medium = '--border-radius-medium',
large = '--border-radius-large'
}
let labelReference: HTMLLabelElement | null = null;
onMount(() => {
if (autofocus) {
labelReference?.focus();
}
});
</script>
<label
bind:this={labelReference}
class="box u-cursor-pointer u-flex u-flex-vertical u-gap-16"
class:is-allow-focus={!disabled}
class:is-disabled={disabled}
+2 -2
View File
@@ -70,7 +70,6 @@
}
$: state = $isTabletViewport ? 'closed' : getSidebarState();
$: isOnProjectSettings = /^\/console\/project-[a-zA-Z0-9-]+\/settings$/.test(page.url.pathname);
const projectOptions = [
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
@@ -289,7 +288,8 @@
on:click={() => {
trackEvent('click_menu_settings');
}}
class:active={isOnProjectSettings}
class:active={page.url.pathname.includes('/settings') &&
!page.url.pathname.includes('sites')}
><span class="link-icon"><Icon icon={IconCog} size="s" /></span><span
class:no-text={state === 'icons'}
class:has-text={state === 'open'}
+9 -109
View File
@@ -1,21 +1,11 @@
<script lang="ts">
import { InputCheckbox } from '$lib/elements/forms';
import { page } from '$app/state';
import type { Writable } from 'svelte/store';
import { preferences } from '$lib/stores/preferences';
import { onMount } from 'svelte';
import { View } from '$lib/helpers/load';
import type { Column } from '$lib/helpers/types';
import {
ActionMenu,
Icon,
Layout,
Popover,
ToggleButton,
Button
} from '@appwrite.io/pink-svelte';
import { IconViewBoards, IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { goto } from '$app/navigation';
import { Icon, Button } from '@appwrite.io/pink-svelte';
import { IconViewBoards } from '@appwrite.io/pink-icons-svelte';
import ViewToggle from './viewToggle.svelte';
import ColumnSelector from './columnSelector.svelte';
export let columns: Writable<Column[]>;
export let view: View;
@@ -23,68 +13,11 @@
export let hideView = false;
export let hideColumns = false;
export let allowNoColumns = false;
onMount(async () => {
if (isCustomCollection) {
const prefs = preferences.getCustomCollectionColumns(page.params.collection);
columns.set(
$columns.map((column) => {
column.hide = prefs?.includes(column.id) ?? false;
return column;
})
);
} else {
const prefs = preferences.get(page.route);
// Override the shown columns only if a preference was set
if (prefs?.columns) {
columns.set(
$columns.map((column) => {
column.hide = prefs.columns?.includes(column.id) ?? false;
return column;
})
);
}
}
columns.subscribe((ctx) => {
const columns = ctx.filter((n) => n.hide === true).map((n) => n.id);
if (isCustomCollection) {
preferences.setCustomCollectionColumns(columns);
} else {
preferences.setColumns(columns);
}
});
});
function getViewLink(view: View): string {
const url = new URL(page.url);
url.searchParams.set('view', view);
return url.toString();
}
function updateViewPreferences(view: View) {
preferences.setView(view);
}
function onViewChange(event: CustomEvent<View>) {
updateViewPreferences(event.detail);
goto(getViewLink(event.detail), {
replaceState: true
});
}
$: selectedColumnsNumber = $columns.reduce((acc, column) => {
if (column.hide === true) return acc;
return ++acc;
}, 0);
</script>
{#if !hideColumns && view === View.Table}
{#if $columns?.length}
<Popover let:toggle placement="bottom-end">
<ColumnSelector {columns} {isCustomCollection} {allowNoColumns}>
{#snippet children(toggle, selectedColumnsNumber)}
<Button.Button
size="s"
variant="secondary"
@@ -92,43 +25,10 @@
on:click={toggle}>
<Icon slot="start" icon={IconViewBoards} />
</Button.Button>
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
<Layout.Stack>
{#each $columns as column}
{#if !column?.exclude}
<InputCheckbox
id={column.id}
label={column.title}
on:change={() => (column.hide = !column.hide)}
checked={!column.hide}
disabled={allowNoColumns
? false
: selectedColumnsNumber <= 1 && column.hide !== true} />
{/if}
{/each}
</Layout.Stack>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
{/if}
{/snippet}
</ColumnSelector>
{/if}
{#if !hideView}
<ToggleButton
--bgcolor-neutral-default="var(--bgcolor-neutral-primary)"
on:change={onViewChange}
active={view}
buttons={[
{
id: 'table',
label: 'table view',
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
icon: IconViewGrid
}
]} />
<ViewToggle bind:view />
{/if}
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { View } from '$lib/helpers/load';
import { preferences } from '$lib/stores/preferences';
import { IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { ToggleButton } from '@appwrite.io/pink-svelte';
let {
view = $bindable()
}: {
view: View;
} = $props();
function onViewChange(event: CustomEvent<View>) {
updateViewPreferences(event.detail);
goto(getViewLink(event.detail), {
replaceState: true
});
}
function getViewLink(view: View): string {
const url = new URL(page.url);
url.searchParams.set('view', view);
return url.toString();
}
function updateViewPreferences(view: View) {
preferences.setView(view);
}
</script>
<ToggleButton
--bgcolor-neutral-default="var(--bgcolor-neutral-primary)"
on:change={onViewChange}
active={view}
buttons={[
{
id: 'table',
label: 'table view',
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
icon: IconViewGrid
}
]} />
+3 -1
View File
@@ -1,6 +1,7 @@
export const PAGE_LIMIT = 12; // default page limit
export const CARD_LIMIT = 6; // default card limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
export enum Dependencies {
FACTORS = 'dependency:factors',
@@ -502,7 +503,8 @@ export enum BillingPlan {
PRO = 'tier-1',
SCALE = 'tier-2',
GITHUB_EDUCATION = 'auto-1',
CUSTOM = 'cont-1'
CUSTOM = 'cont-1',
ENTERPRISE = 'ent-1'
}
export const feedbackDowngradeOptions = [
+3 -6
View File
@@ -12,13 +12,10 @@
export function getFlag(country: string, width: number, height: number, quality: number) {
if (!isValueOfStringEnum(Flag, country)) return '';
let flag = sdk.forConsole.avatars
return sdk.forConsole.avatars
.getFlag(country, width * 2, height * 2, quality)
?.toString();
flag?.includes('&project=')
? (flag = flag.replace('&project=', '&mode=admin'))
: flag + '&mode=admin';
return flag;
?.toString()
?.replace('&project=console', '&mode=admin');
}
</script>
+1 -6
View File
@@ -1,10 +1,5 @@
<script lang="ts">
import { Selector } from '@appwrite.io/pink-svelte';
import type Radio from '@appwrite.io/pink-svelte/dist/selector/Radio.svelte';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<Radio>;
export let label: string = null;
export let id: string;
@@ -13,7 +8,7 @@
export let name: string;
export let required = false;
export let disabled = false;
export let size: Props['size'] = 'm';
export let size: 's' | 'm' = 'm';
</script>
<Selector.Radio
@@ -1,7 +1,7 @@
<script lang="ts">
import { DropList } from '$lib/components';
import { SelectSearchCheckbox } from '..';
import { Icon } from '@appwrite.io/pink-svelte';
import { Icon, Layout, Tag } from '@appwrite.io/pink-svelte';
import { IconChevronDown, IconChevronUp } from '@appwrite.io/pink-icons-svelte';
type Option = {
@@ -50,20 +50,18 @@
class="tags-input u-position-relative u-cursor-pointer"
type="button"
on:click={() => (show = !show)}>
<div class="tags">
<ul class="tags-list">
{#each tags as tag}
<li class="tags-item">
<div class="input-tag">
<span class="tag-text">{tag}</span>
</div>
</li>
{/each}
</ul>
</div>
<div class="input">
<input {placeholder} bind:value={search} bind:this={input} />
<div class="tags-container">
{#each tags as tag}
<Tag size="xs">
{tag}
</Tag>
{/each}
</div>
{#if !tags.length}
<input {placeholder} bind:value={search} bind:this={input} />
{/if}
<Icon size="m" icon={show ? IconChevronUp : IconChevronDown} />
</div>
@@ -84,13 +82,11 @@
</svelte:fragment>
</DropList>
<style>
<style lang="scss">
.tags-input {
width: 100%;
}
@media (max-width: 768px) {
.tags-input {
@media (max-width: 768px) {
padding-right: 2rem;
}
}
@@ -100,26 +96,40 @@
display: flex;
align-items: center;
transition: all 0.15s ease-in-out;
border: var(--border-width-s) solid var(--border-neutral);
padding-inline: var(--space-6);
border-radius: var(--border-radius-s);
background-color: var(--p-input-background-color);
padding-inline: var(--space-6);
outline-offset: calc(var(--border-width-s) * -1);
border: var(--border-width-s) solid var(--border-neutral);
--p-input-background-color: var(--input-background-color, var(--bgcolor-neutral-default));
& input {
inline-size: 100%;
padding-block: var(--space-3);
padding-inline: 0;
border: none;
display: block;
line-height: 140%;
background: none;
}
& input::placeholder {
color: var(--fgcolor-neutral-tertiary);
}
&:focus-within {
outline: var(--border-width-l) solid var(--border-focus);
}
}
.input input {
inline-size: 100%;
padding-block: var(--space-3);
padding-inline: 0;
border: none;
display: block;
line-height: 140%;
background: none;
}
.input input::placeholder {
color: var(--fgcolor-neutral-tertiary);
}
.input:focus-within {
outline: var(--border-width-l) solid var(--border-focus);
.tags-container {
flex: 1;
display: flex;
flex-wrap: wrap;
overflow-y: auto;
max-height: 14rem;
gap: var(--space-2);
align-items: center;
align-content: space-between;
padding-block: var(--space-2);
}
</style>
+8
View File
@@ -6,6 +6,8 @@
export let name: string = id;
export let helper: string = undefined;
export let value = '';
export let pattern: string = null; //TODO: implement pattern check
export let patternError: string = '';
export let placeholder = '';
export let required = false;
export let nullable = false;
@@ -27,6 +29,12 @@
return;
}
error = inputNode.validationMessage;
if (patternError && inputNode.validity.patternMismatch) {
error = patternError;
return;
}
error = inputNode.validationMessage;
};
+5 -15
View File
@@ -1,21 +1,11 @@
<script lang="ts">
import { Tooltip } from '@appwrite.io/pink-svelte';
interface $$Props extends Partial<HTMLLabelElement> {
required?: boolean;
hideRequired?: boolean;
optionalText?: string | undefined;
hide?: boolean;
tooltip?: string;
for?: string;
class?: string;
}
export let required: $$Props['required'] = false;
export let hideRequired: $$Props['hideRequired'] = false;
export let optionalText: $$Props['optionalText'] = undefined;
export let hide: $$Props['hide'] = false;
export let tooltip: $$Props['tooltip'] = null;
export let required = false;
export let hideRequired = false;
export let optionalText: string | undefined = undefined;
export let hide = false;
export let tooltip: string = null;
</script>
<label
+1 -1
View File
@@ -6,7 +6,7 @@
export let selected: string[] = [];
export let pageItemsIds: string[] = [];
function handleClick(e: MouseEvent) {
function handleClick(e: CustomEvent) {
if (!isHTMLInputElement(e.target)) return;
if (e.target.checked) {
const set = new Set(selected);
-382
View File
@@ -1,382 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseDnsRecords } from './domains';
describe('parseDnsRecords', () => {
it('should parse standard zone file with origin', () => {
const content = `$ORIGIN example.com. ; designates the start of this zone file in the namespace
$TTL 3600 ; default expiration time (in seconds) of all RRs without their own TTL value
example.com. IN SOA ns.example.com. username.example.com. ( 2020091025 7200 3600 1209600 3600 )
example.com. IN NS ns ; ns.example.com is a nameserver for example.com
example.com. IN NS ns.somewhere.example. ; ns.somewhere.example is a backup nameserver for example.com
example.com. IN MX 10 mail.example.com. ; mail.example.com is the mailserver for example.com
@ IN MX 20 mail2.example.com. ; equivalent to above line, "@" represents zone origin
@ IN MX 50 mail3 ; equivalent to above line, but using a relative host name
example.com. IN A 192.0.2.1 ; IPv4 address for example.com
IN AAAA 2001:db8:10::1 ; IPv6 address for example.com
ns IN A 192.0.2.2 ; IPv4 address for ns.example.com
IN AAAA 2001:db8:10::2 ; IPv6 address for ns.example.com
www IN CNAME example.com. ; www.example.com is an alias for example.com
wwwtest IN CNAME www ; wwwtest.example.com is another alias for www.example.com
mail IN A 192.0.2.3 ; IPv4 address for mail.example.com
mail2 IN A 192.0.2.4 ; IPv4 address for mail2.example.com
mail3 IN A 192.0.2.5 ; IPv4 address for mail3.example.com`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(2);
expect(result.NS[0].name).toBe('example.com');
expect(result.NS[0].value).toBe('ns');
expect(result.NS[1].name).toBe('example.com');
expect(result.NS[1].value).toBe('ns.somewhere.example');
expect(result.MX).toHaveLength(3);
expect(result.MX[0].name).toBe('example.com');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('mail.example.com');
expect(result.MX[1].name).toBe('');
expect(result.MX[1].priority).toBe(20);
expect(result.MX[1].value).toBe('mail2.example.com');
expect(result.MX[2].name).toBe('');
expect(result.MX[2].priority).toBe(50);
expect(result.MX[2].value).toBe('mail3');
expect(result.A).toHaveLength(5);
expect(result.A[0].name).toBe('example.com');
expect(result.A[0].value).toBe('192.0.2.1');
expect(result.A[1].name).toBe('ns');
expect(result.A[1].value).toBe('192.0.2.2');
expect(result.A[2].name).toBe('mail');
expect(result.A[2].value).toBe('192.0.2.3');
expect(result.A[3].name).toBe('mail2');
expect(result.A[3].value).toBe('192.0.2.4');
expect(result.A[4].name).toBe('mail3');
expect(result.A[4].value).toBe('192.0.2.5');
expect(result.AAAA).toHaveLength(2);
expect(result.AAAA[0].name).toBe('example.com');
expect(result.AAAA[0].value).toBe('2001:db8:10::1');
expect(result.AAAA[1].name).toBe('ns');
expect(result.AAAA[1].value).toBe('2001:db8:10::2');
expect(result.CNAME).toHaveLength(2);
expect(result.CNAME[0].name).toBe('www');
expect(result.CNAME[0].value).toBe('example.com');
expect(result.CNAME[1].name).toBe('wwwtest');
expect(result.CNAME[1].value).toBe('www');
});
it('should parse localhost zone file', () => {
const content = `$ORIGIN localhost.
@ 86400 IN SOA @ root (
1999010100 ; serial
10800 ; refresh (3 hours)
900 ; retry (15 minutes)
604800 ; expire (1 week)
86400 ; minimum (1 day)
)
@ 86400 IN NS @
@ 86400 IN A 127.0.0.1
@ 86400 IN AAAA ::1`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(1);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('@');
expect(result.NS[0].ttl).toBe(86400);
expect(result.A).toHaveLength(1);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('127.0.0.1');
expect(result.A[0].ttl).toBe(86400);
expect(result.AAAA).toHaveLength(1);
expect(result.AAAA[0].name).toBe('');
expect(result.AAAA[0].value).toBe('::1');
expect(result.AAAA[0].ttl).toBe(86400);
});
it('should parse complex zone file with multiple record types and comments', () => {
const content = `; Exported (y-m-d hh:mm:ss): 2019-01-10 13:05:04
;
; This file is intended for use for informational and archival
; purposes ONLY and MUST be edited before use on a production
; DNS server.
;
; In particular, you must update the SOA record with the correct
; authoritative name server and contact e-mail address information,
; and add the correct NS records for the name servers which will
; be authoritative for this domain.
;
; For further information, please consult the BIND documentation
; located on the following website:
;
; http://www.isc.org/
;
; And RFC 1035:
;
; http://www.ietf.org/rfc/rfc1035.txt
;
; Please note that we do NOT offer technical support for any use
; of this zone data, the BIND name server, or any other third-
; party DNS software.
;
; Use at your own risk.
; SOA Record
example.com. 3600 IN SOA ns41.domaincontrol.com. dns.net. (
2018122702
28800
7200
604800
3600
)
; A Records
@ 600 IN A 192.0.2.249
blog 10800 IN A 192.0.2.255
dev 1800 IN A 192.0.2.254
dev01 1800 IN A 192.0.2.253
dev02 1800 IN A 192.0.2.252
dev03 1800 IN A 192.0.2.251
dev04 1800 IN A 192.0.2.250
; CNAME Records
abc123b432dc7785b7ef31f04f25c3e71 1800 IN CNAME verify.bing.com.
akamai 600 IN CNAME www.example.com.edgekey.net.
email 3600 IN CNAME email.secureserver.net.
; MX Records
@ 604800 IN MX 10 amlxe.l.google.com.
@ 604800 IN MX 10 aplxe.l.google.com.
; TXT Records
@ 3600 IN TXT "google-site-verification=3J82-80dbMyCo5Q5C1G11JszeOnZPGCSYlHcPcXg"
@ 3600 IN TXT "google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB743xsbX918"`;
const result = parseDnsRecords(content);
expect(result.A).toHaveLength(7);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('192.0.2.249');
expect(result.A[0].ttl).toBe(600);
expect(result.A[1].name).toBe('blog');
expect(result.A[1].value).toBe('192.0.2.255');
expect(result.A[1].ttl).toBe(10800);
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('abc123b432dc7785b7ef31f04f25c3e71');
expect(result.CNAME[0].value).toBe('verify.bing.com');
expect(result.CNAME[0].ttl).toBe(1800);
expect(result.CNAME[1].name).toBe('akamai');
expect(result.CNAME[1].value).toBe('www.example.com.edgekey.net');
expect(result.CNAME[1].ttl).toBe(600);
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].value).toBe('amlxe.l.google.com');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].ttl).toBe(604800);
expect(result.MX[1].name).toBe('');
expect(result.MX[1].value).toBe('aplxe.l.google.com');
expect(result.MX[1].priority).toBe(10);
expect(result.MX[1].ttl).toBe(604800);
expect(result.TXT).toHaveLength(2);
expect(result.TXT[0].name).toBe('');
expect(result.TXT[0].value).toBe(
'google-site-verification=3J82-80dbMyCo5Q5C1G11JszeOnZPGCSYlHcPcXg'
);
expect(result.TXT[0].ttl).toBe(3600);
expect(result.TXT[1].name).toBe('');
expect(result.TXT[1].value).toBe(
'google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB743xsbX918'
);
expect(result.TXT[1].ttl).toBe(3600);
});
it('should parse zone file with origin prefix', () => {
const content = `$ORIGIN example.com.
example.com. 3600 IN SOA ns41.domaincontrol.com. dns.net. (
2018122702
28800
7200
604800
3600
)
; A Records
@ 600 IN A 192.0.2.249
blog 10800 IN A 192.0.2.255
dev 1800 IN A 192.0.2.254
dev01 1800 IN A 192.0.2.253
dev02 1800 IN A 192.0.2.252
dev03 1800 IN A 192.0.2.251
dev04 1800 IN A 192.0.2.250
abc123b432dc7785b7ef31f04f25c3e71 1800 IN CNAME verify.bing.com.
; CNAME Records
akamai 600 IN CNAME www.example.edgekey.net.
email 3600 IN CNAME email.secureserver.net.
; MX Records
@ 604800 IN MX 10 amlxe.l.google.com.
@ 604800 IN MX 10 aplxe.l.google.com.
; TXT Records
@ 3600 IN TXT "google-site-verification=3J82-80dbMyCo5Q5C1GM8os1VYVEOnZPGCSYlHcPcXg"
@ 3600 IN TXT "google-site-verification=eS_QPYLE_W4nduSrlN-cddxG7ZqOnB7k7uIG7qrsyu8"`;
const result = parseDnsRecords(content);
expect(result.A).toHaveLength(7);
expect(result.A[0].name).toBe('');
expect(result.A[0].value).toBe('192.0.2.249');
expect(result.A[0].ttl).toBe(600);
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('abc123b432dc7785b7ef31f04f25c3e71');
expect(result.CNAME[0].value).toBe('verify.bing.com');
expect(result.CNAME[1].name).toBe('akamai');
expect(result.CNAME[1].value).toBe('www.example.edgekey.net');
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('amlxe.l.google.com');
expect(result.TXT).toHaveLength(2);
expect(result.TXT[0].name).toBe('');
expect(result.TXT[0].value).toBe(
'google-site-verification=3J82-80dbMyCo5Q5C1GM8os1VYVEOnZPGCSYlHcPcXg'
);
});
it('should parse complex zone file with multiple servers', () => {
const content = `$ORIGIN example.com.
$TTL 86400
@ IN SOA dns1.example.com. hostmaster.example.com. (
2001062501 ; serial
21600 ; refresh after 6 hours
3600 ; retry after 1 hour
604800 ; expire after 1 week
86400 ) ; minimum TTL of 1 day
IN NS dns1.example.com.
IN NS dns2.example.com.
IN MX 10 mail.example.com.
IN MX 20 mail2.example.com.
dns1 IN A 10.0.1.1
dns2 IN A 10.0.1.2
server1 IN A 10.0.1.5
server2 IN A 10.0.1.6
ftp IN A 10.0.1.3
IN A 10.0.1.4
mail IN CNAME server1
mail2 IN CNAME server2
www IN CNAME server1`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(2);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('dns1.example.com');
expect(result.NS[1].name).toBe('');
expect(result.NS[1].value).toBe('dns2.example.com');
expect(result.MX).toHaveLength(2);
expect(result.MX[0].name).toBe('');
expect(result.MX[0].priority).toBe(10);
expect(result.MX[0].value).toBe('mail.example.com');
expect(result.MX[1].name).toBe('');
expect(result.MX[1].priority).toBe(20);
expect(result.MX[1].value).toBe('mail2.example.com');
expect(result.A).toHaveLength(6);
expect(result.A[0].name).toBe('dns1');
expect(result.A[0].value).toBe('10.0.1.1');
expect(result.A[1].name).toBe('dns2');
expect(result.A[1].value).toBe('10.0.1.2');
expect(result.A[2].name).toBe('server1');
expect(result.A[2].value).toBe('10.0.1.5');
expect(result.A[3].name).toBe('server2');
expect(result.A[3].value).toBe('10.0.1.6');
expect(result.A[4].name).toBe('ftp');
expect(result.A[4].value).toBe('10.0.1.3');
expect(result.A[5].name).toBe('ftp');
expect(result.A[5].value).toBe('10.0.1.4');
expect(result.CNAME).toHaveLength(3);
expect(result.CNAME[0].name).toBe('mail');
expect(result.CNAME[0].value).toBe('server1');
expect(result.CNAME[1].name).toBe('mail2');
expect(result.CNAME[1].value).toBe('server2');
expect(result.CNAME[2].name).toBe('www');
expect(result.CNAME[2].value).toBe('server1');
});
it('should parse reverse zone file with PTR records', () => {
const content = `;; reverse zone file for 127.0.0.1 and ::1
$TTL 1814400 ; 3 weeks
@ 1814400 IN SOA localhost. root.localhost. (
1999010100 ; serial
10800 ; refresh (3 hours)
900 ; retry (15 minutes)
604800 ; expire (1 week)
86400 ; minimum (1 day)
)
@ 1814400 IN NS localhost.
1 1814400 IN PTR localhost.`;
const result = parseDnsRecords(content);
expect(result.NS).toHaveLength(1);
expect(result.NS[0].name).toBe('');
expect(result.NS[0].value).toBe('localhost');
expect(result.NS[0].ttl).toBe(1814400);
expect(result.PTR).toHaveLength(1);
expect(result.PTR[0].name).toBe('1');
expect(result.PTR[0].value).toBe('localhost');
expect(result.PTR[0].ttl).toBe(1814400);
});
it('should handle empty content', () => {
const result = parseDnsRecords('');
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
it('should handle invalid content', () => {
const result = parseDnsRecords('This is not a valid zone file');
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
it('should handle content with only comments', () => {
const result = parseDnsRecords(`; This is a comment
; This is another comment
; And one more comment`);
expect(result.A).toHaveLength(0);
expect(result.AAAA).toHaveLength(0);
expect(result.CNAME).toHaveLength(0);
expect(result.MX).toHaveLength(0);
expect(result.NS).toHaveLength(0);
expect(result.TXT).toHaveLength(0);
expect(result.PTR).toHaveLength(0);
});
});
+17
View File
@@ -0,0 +1,17 @@
// Original code from: https://github.com/bevry/envfile
export type Data = Record<string, string>;
export function parse(src: string): Data {
const result: Data = {};
const lines = src.toString().split('\n');
for (const line of lines) {
const match = line.match(/^([^=:#]+?)[=:](.*)/);
if (match) {
const key = match[1].trim();
const value = match[2].trim().replace(/['"]+/g, '');
result[key] = value;
}
}
return result;
}
+5 -2
View File
@@ -1,7 +1,10 @@
export function abbreviateNumber(num: number, decimals: number = 1): string {
if (isNaN(num)) return String(num);
if (num >= 1000000) {
const result = num / 1000000;
if (num >= 1_000_000_000) {
const result = num / 1_000_000_000;
return result.toFixed(result % 1 !== 0 ? decimals : 0) + 'B';
} else if (num >= 1_000_000) {
const result = num / 1_000_000;
return result.toFixed(result % 1 !== 0 ? decimals : 0) + 'M';
} else if (num >= 1000) {
const result = num / 1000;
+14 -1
View File
@@ -1,6 +1,19 @@
import type { Column as PinkColumn } from '@appwrite.io/pink-svelte/dist/table';
import type { Writable } from 'svelte/store';
export type PinkColumn = {
id: string;
width?:
| {
min: number;
max: number;
}
| {
min: number;
}
| number;
hide?: boolean;
};
export type WritableValue<T> = T extends Writable<infer U> ? U : never;
export function isHTMLElement(el: unknown): el is HTMLElement {
+17
View File
@@ -0,0 +1,17 @@
export function getReferrerAndUtmSource() {
if (sessionStorage) {
let values = {};
if (sessionStorage.getItem('utmReferral')) {
values = { ...values, utmReferral: sessionStorage.getItem('utmReferral') };
}
if (sessionStorage.getItem('utmSource')) {
values = { ...values, utmSource: sessionStorage.getItem('utmSource') };
}
if (sessionStorage.getItem('utmMedium')) {
values = { ...values, utmMedium: sessionStorage.getItem('utmMedium') };
}
return values;
}
return {};
}
+12 -7
View File
@@ -60,13 +60,15 @@
const dispatch = createEventDispatcher();
$: tier = tierToPlan($organization?.billingPlan)?.name;
// these can be organization level limitations as well.
// we need to migrate this sometime later, but soon!
$: hasProjectLimitation =
checkForProjectLimitation(serviceId) && $organization?.billingPlan === BillingPlan.FREE;
$: hasUsageFees = hasProjectLimitation
? checkForUsageFees($organization?.billingPlan, serviceId)
: false;
$: isLimited = limit !== 0 && limit < Infinity;
$: overflowingServices = limitedServices.filter((service) => service.value >= 0);
$: overflowingServices = limitedServices.filter((service) => service.value > 0);
$: isButtonDisabled =
buttonDisabled ||
($readOnly && !GRACE_PERIOD_OVERRIDE) ||
@@ -83,12 +85,14 @@
<!-- Show only if on Cloud, alerts are enabled, and it isn't a project limited service -->
{#if isCloud && showAlert}
{#if $readOnly}
{@const services = overflowingServices
.map((s) => {
return s.name.toLocaleLowerCase();
})
.join(', ')}
<!-- some services are above limit -->
{@const services = overflowingServices
.map((s) => {
return s.name.toLocaleLowerCase();
})
.join(', ')}
{#if services.length}
<slot name="alert" {limit} {tier} {title} {upgradeMethod} {hasUsageFees} {services}>
{#if $organization?.billingPlan !== BillingPlan.FREE && hasUsageFees}
<Alert type="info" isStandalone>
@@ -131,6 +135,7 @@
<svelte:fragment slot="list">
<slot name="tooltip" {limit} {tier} {title} {upgradeMethod} {hasUsageFees}>
{#if hasProjectLimitation}
{@const count = limit > 1 ? serviceId : serviceId.replace('s', '')}
<p class="text">
You are limited to {limit}
{title.toLocaleLowerCase()} per project on the {tier} plan.
@@ -0,0 +1,53 @@
<script lang="ts">
import { Confirm, ViewToggle } from '$lib/components';
import ColumnSelector from '$lib/components/columnSelector.svelte';
import type { View } from '$lib/helpers/load';
import type { Column } from '$lib/helpers/types';
import { IconViewBoards } from '@appwrite.io/pink-icons-svelte';
import { Button, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
import type { Writable } from 'svelte/store';
let {
show = $bindable(false),
hideView = false,
view = $bindable(),
columns,
hideColumns = false,
isCustomCollection = false
}: {
show?: boolean;
hideView?: boolean;
view?: View;
columns?: Writable<Column[]>;
hideColumns?: boolean;
isCustomCollection?: boolean;
} = $props();
</script>
<Confirm title="Adjustments" bind:open={show} canDelete={false}>
<Layout.Stack>
{#if !hideView}
<Layout.Stack gap="xs">
<Typography.Text>Layout</Typography.Text>
<ViewToggle bind:view />
</Layout.Stack>
{/if}
{#if !hideColumns && $columns?.length}
<Layout.Stack gap="xs">
<Typography.Text>Columns</Typography.Text>
<ColumnSelector {columns} {isCustomCollection}>
{#snippet children(toggle, selectedColumnsNumber)}
<Button.Button
size="s"
variant="secondary"
badge={selectedColumnsNumber.toString()}
on:click={toggle}>
Columns
<Icon slot="start" icon={IconViewBoards} />
</Button.Button>
{/snippet}
</ColumnSelector>
</Layout.Stack>
{/if}
</Layout.Stack>
</Confirm>
+8 -1
View File
@@ -156,11 +156,18 @@
}
footer {
margin-block-start: auto;
margin-inline: 2rem;
padding-block: 1rem;
display: flex;
flex-direction: column;
gap: var(--gap-l);
margin-inline: 2rem;
}
:global(main:has(.sub-navigation)) footer {
@media (min-width: 1024px) {
margin-inline-start: -1.5rem;
margin-inline-end: 2rem;
}
}
.extra-margin {
margin-block-start: var(--space-2, 4px);
+1
View File
@@ -18,6 +18,7 @@
}
if (sidebar) {
sidebar.style.top = `${alertHeight + ($isTabletViewport ? 0 : header.getBoundingClientRect().height)}px`;
sidebar.style.height = `calc(100vh - (${alertHeight + ($isTabletViewport ? 0 : header.getBoundingClientRect().height)}px))`;
}
if (contentSection) {
contentSection.style.paddingBlockStart = `${alertHeight}px`;
+1
View File
@@ -22,3 +22,4 @@ export { default as WizardSecondaryContainer } from './wizardSecondaryContainer.
export { default as WizardSecondaryContent } from './wizardSecondaryContent.svelte';
export { default as WizardSecondaryFooter } from './wizardSecondaryFooter.svelte';
export { default as Wizard } from './wizard.svelte';
export { default as ResponsiveContainerHeader } from './responsiveContainerHeader.svelte';
+6
View File
@@ -13,6 +13,12 @@
title={notification.title}
status={notification.type}
description={notification.message}
actions={notification.buttons?.map((button) => {
return {
label: button.name,
onClick: button.method
};
})}
on:dismiss={() => dismissNotification(notification.id)} />
</span>
{/each}
@@ -0,0 +1,166 @@
<script lang="ts">
import { SearchQuery, ViewSelector } from '$lib/components';
import { FiltersBottomSheet, ParsedTagList, queryParamToMap } from '$lib/components/filters';
import QuickFilters from '$lib/components/filters/quickFilters.svelte';
import Button from '$lib/elements/forms/button.svelte';
import { View } from '$lib/helpers/load';
import type { Column } from '$lib/helpers/types';
import { isSmallViewport } from '$lib/stores/viewport';
import { IconAdjustments, IconFilterLine, IconSearch } from '@appwrite.io/pink-icons-svelte';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import type { Snippet } from 'svelte';
import type { Writable } from 'svelte/store';
import DisplaySettingsModal from './displaySettingsModal.svelte';
import { buildFilterCol } from '$lib/components/filters/quickFilters';
import { afterNavigate } from '$app/navigation';
import { setFilters } from '$lib/components/filters/setFilters';
let {
columns,
view = View.Table,
hideView = false,
hideColumns = false,
hasSearch = false,
searchPlaceholder = 'Search by ID',
hasFilters = false,
analyticsSource = '',
children
}: {
columns?: Writable<Column[]>;
view?: View;
hideView?: boolean;
hideColumns?: boolean;
hasSearch?: boolean;
searchPlaceholder?: string;
hasFilters?: boolean;
analyticsSource?: string;
children?: Snippet;
} = $props();
let hasDisplaySettings = $derived(!hideView || (!hideColumns && $columns?.length));
let numberOfOptions = $derived(
[hasSearch, hasFilters && $columns?.length, hasDisplaySettings].filter(Boolean).length
);
let showSearch = $state(false);
let showDisplaySettingsModal = $state(false);
let showFilters = $state(false);
let filterCols = $derived(
$columns
.map((col) => (col.filter !== false ? buildFilterCol(col) : null))
.filter((f) => f?.options)
);
afterNavigate((p) => {
if (!hasFilters) return;
const paramQueries = p.to.url.searchParams.get('query');
const localQueries = queryParamToMap(paramQueries || '[]');
const localTags = Array.from(localQueries.keys());
setFilters(localTags, filterCols, $columns);
filterCols = filterCols;
});
</script>
<header>
<Layout.Stack>
{#if $isSmallViewport}
<Layout.Stack gap="xl">
<Layout.Stack direction="row">
{#if children}
<div style={`--button-width: 100%; width: 100%`}>
{@render children()}
</div>
{/if}
{#if numberOfOptions === 1}
{#if hasSearch}
{@render searchButton(true)}
{:else if hasFilters && $columns?.length}
{@render filtersButton(true)}
{:else if hasDisplaySettings}
{@render settingsButton(true)}
{/if}
{/if}
</Layout.Stack>
{#if numberOfOptions > 1}
<Layout.Stack
direction="row"
gap="s"
style={`--button-width: calc(${100 / numberOfOptions}% - var(--gap-s) / 2)`}>
{#if hasSearch}
{@render searchButton()}
{/if}
{#if hasFilters && $columns?.length}
{@render filtersButton()}
{/if}
{#if hasDisplaySettings}
{@render settingsButton()}
{/if}
</Layout.Stack>
{/if}
{#if showSearch && hasSearch}
<SearchQuery placeholder={searchPlaceholder} />
{/if}
</Layout.Stack>
{:else}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
{#if hasSearch}
<SearchQuery placeholder={searchPlaceholder} />
{/if}
{#if hasFilters && $columns?.length}
<QuickFilters {columns} {analyticsSource} />
{/if}
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if hasDisplaySettings}
<ViewSelector {view} {columns} {hideView} {hideColumns} />
{/if}
{@render children()}
</Layout.Stack>
</Layout.Stack>
{/if}
<ParsedTagList />
</Layout.Stack>
</header>
{#if showDisplaySettingsModal}
<DisplaySettingsModal
bind:show={showDisplaySettingsModal}
{columns}
{hideColumns}
{hideView}
bind:view />
{/if}
{#if $isSmallViewport && showFilters}
<FiltersBottomSheet
bind:openBottomSheet={showFilters}
{columns}
{analyticsSource}
bind:filterCols />
{/if}
<!-- SNIPPETS -->
{#snippet searchButton(icon = false)}
<Button ariaLabel="Search" on:click={() => (showSearch = !showSearch)} secondary {icon}>
<Icon icon={IconSearch} />
</Button>
{/snippet}
{#snippet settingsButton(icon = false)}
<Button
ariaLabel="Display settings"
on:click={() => (showDisplaySettingsModal = !showDisplaySettingsModal)}
secondary
{icon}>
<Icon icon={IconAdjustments} />
</Button>
{/snippet}
{#snippet filtersButton(icon = false)}
<Button ariaLabel="Filters" on:click={() => (showFilters = !showFilters)} secondary {icon}>
<Icon icon={IconFilterLine} />
</Button>
{/snippet}
+4 -9
View File
@@ -10,6 +10,7 @@
import type { Campaign } from '$lib/stores/campaigns';
import { getApiEndpoint } from '$lib/stores/sdk';
import { Typography, Layout, Avatar } from '@appwrite.io/pink-svelte';
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
export const imgLight = LoginLight;
export const imgDark = LoginDark;
@@ -40,12 +41,6 @@
return campaign.description;
}
}
function getImage(image: string) {
const endpoint = getApiEndpoint();
const url = new URL(image, endpoint);
return url.toString();
}
</script>
<main class="grid-1-1 is-full-page" id="main">
@@ -88,7 +83,7 @@
<div style:max-inline-size="30rem" style:height="100%">
<Layout.Stack justifyContent="center" alignItems="center" height="100%" gap="xxxl">
<img
src={getImage(campaign?.image[$app.themeInUse])}
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
class="u-block u-image-object-fit-cover side-bg-img"
alt="promo" />
@@ -114,7 +109,7 @@
<Layout.Stack gap="s" direction="row">
{#if currentReview?.image}
<Avatar
src={getImage(currentReview?.image)}
src={getCampaignImageUrl(currentReview?.image)}
alt={currentReview.name}
size="m" />
{:else}
@@ -147,7 +142,7 @@
</p>
<img
style:max-block-size="2.5rem"
src={getImage(campaign?.image[$app.themeInUse])}
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
alt={coupon?.campaign ?? campaign.$id} />
</div>
{/if}
+15 -2
View File
@@ -41,10 +41,20 @@
export function accumulateFromEndingTotal(
metrics: Models.Metric[],
endingTotal: number
endingTotal: number,
startingDayToFillZero: Date = null
): Array<[string, number]> {
return (metrics ?? []).reduceRight(
(acc, curr) => {
if (startingDayToFillZero !== null && startingDayToFillZero instanceof Date) {
const date = new Date(curr.date);
if (date > startingDayToFillZero) {
acc.data.unshift([date.toISOString(), 0]);
acc.total -= 0;
return acc;
}
}
acc.data.unshift([curr.date, acc.total]);
acc.total -= curr.value;
return acc;
@@ -77,6 +87,7 @@
export let countMetadata: MetricMetadata;
export let path: string = null;
export let hidePeriodSelect = false;
export let isCumulative: boolean = false;
</script>
<Layout.Stack gap="s">
@@ -116,7 +127,9 @@
series={[
{
name: countMetadata.legend,
data: accumulateFromEndingTotal(count, total)
data: isCumulative
? count.map((m) => [m.date, m.value])
: accumulateFromEndingTotal(count, total)
}
]} />
</div>
+5 -1
View File
@@ -13,6 +13,7 @@
export let count: Models.Metric[][];
export let legendData: LegendData[];
export let showHeader: boolean = true;
export let legendNumberFormat: 'comma' | 'abbreviate' = 'comma';
</script>
<div>
@@ -55,7 +56,10 @@
}))} />
{#if legendData}
<Legend {legendData} />
<Legend
{legendData}
decimalsForAbbreviate={2}
numberFormat={legendNumberFormat} />
{/if}
</div>
{/if}
+5 -1
View File
@@ -1,8 +1,12 @@
<script>
export let hideSidebar = false;
</script>
<div class="wizard-secondary-content">
<div class="wizard-secondary-content-1">
<slot />
</div>
{#if $$slots.aside}
{#if !hideSidebar}
<div class="wizard-secondary-content-sep"></div>
<div class="wizard-secondary-content-2">
<slot name="aside" />
+1 -2
View File
@@ -31,7 +31,6 @@
import { page } from '$app/state';
import { base } from '$app/paths';
export let search: string = null;
export let rules: Models.ProxyRuleList;
export let dependency: Dependencies;
@@ -58,7 +57,7 @@
{#if $canWriteRules}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery {search} placeholder="Search by name" />
<SearchQuery placeholder="Search by name" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<Button
+198 -27
View File
@@ -1,8 +1,8 @@
import type { Client, Models } from '@appwrite.io/console';
import type { Organization, OrganizationList } from '../stores/organization';
import type { PaymentMethod } from '@stripe/stripe-js';
import type { Tier } from '$lib/stores/billing';
import type { Campaign } from '$lib/stores/campaigns';
import type { Client, Models } from '@appwrite.io/console';
import type { PaymentMethod } from '@stripe/stripe-js';
import type { Organization, OrganizationError, OrganizationList } from '../stores/organization';
export type PaymentMethodData = {
$id: string;
@@ -65,6 +65,32 @@ export type InvoiceList = {
total: number;
};
export type Estimation = {
amount: number;
grossAmount: number;
credits: number;
discount: number;
items: EstimationItem[];
discounts: EstimationItem[];
trialDays: number;
trialEndDate: string | undefined;
error: string | undefined;
};
export type EstimationItem = {
label: string;
value: number;
};
export type EstimationDeleteOrganization = {
amount: number;
grossAmount: number;
credits: number;
discount: number;
items: EstimationItem[];
unpaidInvoices: Invoice[];
};
export type Coupon = {
$id: string;
code: string;
@@ -147,10 +173,12 @@ export type Aggregation = {
* Total amount of the invoice.
*/
amount: number;
additionalMembers: number;
/**
* Price for additional members
*/
additionalMembers: number;
additionalMemberAmount: number;
/**
* Total storage usage.
*/
@@ -175,6 +203,10 @@ export type Aggregation = {
* Usage logs for the billing period.
*/
resources: OrganizationUsage;
/**
* Aggregation billing plan
*/
plan: string;
};
export type OrganizationUsage = {
@@ -182,11 +214,13 @@ export type OrganizationUsage = {
executions: Array<Models.Metric>;
databasesReads: Array<Models.Metric>;
databasesWrites: Array<Models.Metric>;
imageTransformations: Array<Models.Metric>;
executionsTotal: number;
filesStorageTotal: number;
buildsStorageTotal: number;
databasesReadsTotal: number;
databasesWritesTotal: number;
imageTransformationsTotal: number;
deploymentsStorageTotal: number;
executionsMBSecondsTotal: number;
buildsMBSecondsTotal: number;
@@ -204,6 +238,7 @@ export type OrganizationUsage = {
users: number;
authPhoneTotal: number;
authPhoneEstimate: number;
imageTransformations: number;
}>;
authPhoneTotal: number;
authPhoneEstimate: number;
@@ -264,14 +299,25 @@ export type AdditionalResource = {
multiplier?: number;
};
export type PlanAddon = {
supported: boolean;
currency: string;
invoiceDesc: string;
price: number;
limit: number;
value: number;
type: string;
};
export type Plan = {
$id: string;
name: string;
desc: string;
price: number;
order: number;
bandwidth: number;
storage: number;
members: number;
imageTransformations: number;
webhooks: number;
users: number;
teams: number;
@@ -283,7 +329,7 @@ export type Plan = {
realtime: number;
logs: number;
authPhone: number;
addons: {
usage: {
bandwidth: AdditionalResource;
executions: AdditionalResource;
member: AdditionalResource;
@@ -291,7 +337,11 @@ export type Plan = {
storage: AdditionalResource;
users: AdditionalResource;
};
addons: {
seats: PlanAddon;
};
trialDays: number;
budgetCapEnabled: boolean;
isAvailable: boolean;
selfService: boolean;
premiumSupport: boolean;
@@ -300,9 +350,10 @@ export type Plan = {
backupsEnabled: boolean;
backupPolicies: number;
emailBranding: boolean;
supportsCredits: boolean;
};
export type PlansInfo = {
export type PlanList = {
plans: Plan[];
total: number;
};
@@ -337,20 +388,45 @@ export class Billing {
);
}
async validateOrganization(organizationId: string, invites: string[]): Promise<Organization> {
const path = `/organizations/${organizationId}/validate`;
const params = {
organizationId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'PATCH',
uri,
{
'content-type': 'application/json'
},
params
);
}
async createOrganization(
organizationId: string,
name: string,
billingPlan: string,
paymentMethodId: string,
billingAddressId: string = undefined
): Promise<Organization> {
billingAddressId: string = null,
couponId: string = null,
invites: Array<string> = [],
budget: number = undefined,
taxId: string = null
): Promise<Organization | OrganizationError> {
const path = `/organizations`;
const params = {
organizationId,
name,
billingPlan,
paymentMethodId,
billingAddressId
billingAddressId,
couponId,
invites,
budget,
taxId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
@@ -363,6 +439,28 @@ export class Billing {
);
}
async estimationCreateOrganization(
billingPlan: string,
couponId: string = null,
invites: Array<string> = []
): Promise<Estimation> {
const path = `/organizations/estimations/create-organization`;
const params = {
billingPlan,
couponId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
uri,
{
'content-type': 'application/json'
},
params
);
}
async deleteOrganization(organizationId: string): Promise<Organization> {
const path = `/organizations/${organizationId}`;
const params = {
@@ -379,20 +477,30 @@ export class Billing {
);
}
async getPlan(organizationId: string): Promise<Plan> {
const path = `/organizations/${organizationId}/plan`;
const params = {
organizationId
};
async estimationDeleteOrganization(
organizationId: string
): Promise<EstimationDeleteOrganization> {
const path = `/organizations/${organizationId}/estimations/delete-organization`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'get',
uri,
{
'content-type': 'application/json'
},
params
);
return await this.client.call('patch', uri, {
'content-type': 'application/json'
});
}
async getOrganizationPlan(organizationId: string): Promise<Plan> {
const path = `/organizations/${organizationId}/plan`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('get', uri, {
'content-type': 'application/json'
});
}
async getPlan(planId: string): Promise<Plan> {
const path = `/console/plans/${planId}`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('get', uri, {
'content-type': 'application/json'
});
}
async getRoles(organizationId: string): Promise<Roles> {
@@ -407,14 +515,22 @@ export class Billing {
organizationId: string,
billingPlan: string,
paymentMethodId: string,
billingAddressId: string = undefined
): Promise<Organization> {
billingAddressId: string = undefined,
couponId: string = null,
invites: Array<string> = [],
budget: number = undefined,
taxId: string = null
): Promise<Organization | OrganizationError> {
const path = `/organizations/${organizationId}/plan`;
const params = {
organizationId,
billingPlan,
paymentMethodId,
billingAddressId
billingAddressId,
couponId,
invites,
budget,
taxId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
@@ -427,6 +543,37 @@ export class Billing {
);
}
async estimationUpdatePlan(
organizationId: string,
billingPlan: string,
couponId: string = null,
invites: Array<string> = []
): Promise<Estimation> {
const path = `/organizations/${organizationId}/estimations/update-plan`;
const params = {
billingPlan,
couponId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
uri,
{
'content-type': 'application/json'
},
params
);
}
async cancelDowngrade(organizationId: string): Promise<Organization | OrganizationError> {
const path = `/organizations/${organizationId}/plan/cancel`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('patch', uri, {
'content-type': 'application/json'
});
}
async updateBudget(
organizationId: string,
budget: number,
@@ -594,6 +741,14 @@ export class Billing {
);
}
async updateInvoiceStatus(organizationId: string, invoiceId: string): Promise<Invoice> {
const path = `/organizations/${organizationId}/invoices/${invoiceId}/status`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('PATCH', uri, {
'content-type': 'application/json'
});
}
async retryPayment(
organizationId: string,
invoiceId: string,
@@ -750,6 +905,22 @@ export class Billing {
}
}
async getCouponAccount(couponId: string): Promise<Coupon> {
const path = `/account/coupons/${couponId}`;
const params = {
couponId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'GET',
uri,
{
'content-type': 'application/json'
},
params
);
}
async getCoupon(couponId: string): Promise<Coupon> {
const path = `/console/coupons/${couponId}`;
const params = {
@@ -1120,7 +1291,7 @@ export class Billing {
);
}
async getPlansInfo(): Promise<PlansInfo> {
async getPlansInfo(): Promise<PlanList> {
const path = `/console/plans`;
const params = {};
const uri = new URL(this.client.config.endpoint + path);
+19 -1
View File
@@ -185,6 +185,14 @@ export type UsageBuckets = {
* Aggregated statistics of bucket storage files per period.
*/
storage: Metric[];
/**
* Aggregated statistics of bucket image transformations per period.
*/
imageTransformations: Metric[];
/**
* Total aggregated number of bucket image transformations.
*/
imageTransformationsTotal: number;
};
/**
* UsageFunctions
@@ -311,7 +319,17 @@ export type UsageProject = {
authPhoneEstimate: number;
/**
* Aggregated statistics of total number SMS by country
* Aggregated statistics of total number SMS by country.
*/
authPhoneCountriesBreakdown: Models.MetricBreakdown[];
/**
* Array of image transformations per period.
*/
imageTransformations: Metric[];
/**
* Aggregated statistics of total number of image transformations.
*/
imageTransformationsTotal: number;
};
+113 -54
View File
@@ -1,40 +1,41 @@
import { page } from '$app/stores';
import { derived, get, writable } from 'svelte/store';
import { sdk } from './sdk';
import { organization, type Organization } from './organization';
import type {
InvoiceList,
AddressesList,
Invoice,
PaymentList,
PlansMap,
PaymentMethodData,
OrganizationUsage,
Plan
} from '$lib/sdk/billing';
import { isCloud } from '$lib/system';
import { cachedStore } from '$lib/helpers/cache';
import { Query } from '@appwrite.io/console';
import { headerAlert } from './headerAlert';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import { addNotification, notifications } from './notifications';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
import { BillingPlan } from '$lib/constants';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { page } from '$app/stores';
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
import { last } from '$lib/helpers/array';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
import { sizeToBytes, type Size } from '$lib/helpers/sizeConvertion';
import { user } from './user';
import { browser } from '$app/environment';
import type {
AddressesList,
Aggregation,
Invoice,
InvoiceList,
PaymentList,
PaymentMethodData,
Plan,
PlansMap
} from '$lib/sdk/billing';
import { isCloud } from '$lib/system';
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
import { Query } from '@appwrite.io/console';
import { derived, get, writable } from 'svelte/store';
import { headerAlert } from './headerAlert';
import { addNotification, notifications } from './notifications';
import { organization, type Organization, type OrganizationError } from './organization';
import { canSeeBilling } from './roles';
import { sdk } from './sdk';
import { user } from './user';
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
export const roles = [
{
@@ -59,12 +60,20 @@ export const roles = [
}
];
export const teamStatusReadonly = 'readonly';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
export const daysLeftInTrial = writable<number>(0);
export const readOnly = writable<boolean>(false);
export const showBudgetAlert = derived(
page,
($page) => ($page.data.organization?.billingLimits.budgetLimit ?? 0) >= 100
);
export function getRoleLabel(role: string) {
return roles.find((r) => r.value === role)?.label ?? role;
}
@@ -81,6 +90,8 @@ export function tierToPlan(tier: Tier) {
return tierGitHubEducation;
case BillingPlan.CUSTOM:
return tierCustom;
case BillingPlan.ENTERPRISE:
return tierEnterprise;
default:
return tierFree;
}
@@ -130,7 +141,8 @@ export type PlanServices =
| 'usersAddon'
| 'webhooks'
| 'sites'
| 'authPhone';
| 'authPhone'
| 'imageTransformations';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
if (!isCloud) return 0;
@@ -138,6 +150,14 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
const info = get(plansInfo);
if (!info) return 0;
plan ??= info.get(tier ?? get(organization)?.billingPlan);
// members are no longer a variable on plan itself!
// the correct info for members/seats, resides in `addons`.
// plan > addons > seats/others
if (serviceId === 'members') {
// some don't include `limit`, so we fallback!
return plan?.['addons']['seats']['limit'] ?? 1;
}
return plan?.[serviceId] ?? 0;
}
@@ -151,11 +171,12 @@ export const failedInvoice = cachedStore<
load: async (orgId) => {
if (!isCloud) set(null);
if (!get(canSeeBilling)) set(null);
const invoices = await sdk.forConsole.billing.listInvoices(orgId);
const failedInvoices = invoices.invoices.filter((i) => i.status === 'failed');
const failedInvoices = await sdk.forConsole.billing.listInvoices(orgId, [
Query.equal('status', 'failed')
]);
// const failedInvoices = invoices.invoices;
if (failedInvoices?.length > 0) {
const firstFailed = failedInvoices[0];
if (failedInvoices?.invoices?.length > 0) {
const firstFailed = failedInvoices.invoices[0];
const today = new Date();
const thirtyDaysAgo = new Date(today.setDate(today.getDate() - 30));
const failedDate = new Date(firstFailed.$createdAt);
@@ -200,6 +221,11 @@ export const tierCustom: TierData = {
description: 'Team on a custom contract'
};
export const tierEnterprise: TierData = {
name: 'Enterprise',
description: 'For enterprises that need more power and premium support.'
};
export const showUsageRatesModal = writable<boolean>(false);
export function checkForUsageFees(plan: Tier, id: PlanServices) {
@@ -257,13 +283,35 @@ export function calculateTrialDay(org: Organization) {
}
export async function checkForUsageLimit(org: Organization) {
if (org?.billingPlan !== BillingPlan.FREE) {
if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
headerAlert.add({
id: 'teamReadOnlyFailedInvoices',
component: TeamReadonlyAlert,
show: true,
importance: 11
});
readOnly.set(true);
return;
}
if (!org?.billingLimits && org?.status !== teamStatusReadonly) {
readOnly.set(false);
return;
}
if (!org?.billingLimits) {
readOnly.set(false);
return;
if (org?.billingPlan !== BillingPlan.FREE) {
const { budgetLimit } = org?.billingLimits ?? {};
if (budgetLimit && budgetLimit >= 100) {
readOnly.set(false);
headerAlert.add({
id: 'budgetLimit',
component: BudgetLimitAlert,
show: true,
importance: 10
});
readOnly.set(true);
return;
}
}
const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
const resources = [
@@ -276,7 +324,8 @@ export async function checkForUsageLimit(org: Organization) {
const members = org.total;
const plan = get(plansInfo)?.get(org.billingPlan);
const membersOverflow = members > plan.members ? members - (plan.members || members) : 0;
const membersOverflow =
members - 1 > plan.addons.seats.limit ? members - (plan.addons.seats.limit || members) : 0;
if (resources.some((r) => r.value >= 100) || membersOverflow > 0) {
readOnly.set(true);
@@ -456,30 +505,36 @@ export async function checkForNewDevUpgradePro(org: Organization) {
if (now - accountCreated < 1000 * 60 * 60 * 24 * 7) return;
const isDismissed = !!localStorage.getItem('newDevUpgradePro');
if (isDismissed) return;
if (now - accountCreated < 1000 * 60 * 60 * 24 * 37) {
headerAlert.add({
id: 'newDevUpgradePro',
component: newDevUpgradePro,
show: true,
importance: 1
});
// check if coupon already applied
try {
await sdk.forConsole.billing.getCouponAccount(NEW_DEV_PRO_UPGRADE_COUPON);
} catch (e) {
return;
}
headerAlert.add({
id: 'newDevUpgradePro',
component: newDevUpgradePro,
show: true,
importance: 1
});
}
export const upgradeURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/change-plan`
);
export const billingURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/billing`
);
export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account'];
export function calculateExcess(usage: OrganizationUsage, plan: Plan, members: number) {
const totBandwidth = usage?.bandwidth?.length > 0 ? last(usage.bandwidth).value : 0;
export function calculateExcess(addon: Aggregation, plan: Plan) {
return {
bandwidth: calculateResourceSurplus(totBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(usage?.storageTotal, plan.storage, 'GB'),
users: calculateResourceSurplus(usage?.usersTotal, plan.users),
executions: calculateResourceSurplus(usage?.executionsTotal, plan.executions, 'GB'),
members: calculateResourceSurplus(members, plan.members)
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
executions: calculateResourceSurplus(addon.usageExecutions, plan.executions, 'GB'),
members: addon.additionalMembers
};
}
@@ -488,3 +543,7 @@ export function calculateResourceSurplus(total: number, limit: number, limitUnit
const realLimit = (limitUnit ? sizeToBytes(limit, limitUnit) : limit) || Infinity;
return total > realLimit ? total - realLimit : 0;
}
export function isOrganization(org: Organization | OrganizationError): org is Organization {
return (org as Organization).$id !== undefined;
}
+14 -9
View File
@@ -1,10 +1,12 @@
import { derived, writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { page } from '$app/stores';
import { type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { headerAlert } from '$lib/stores/headerAlert';
import BackupDatabase from '$lib/components/backupDatabaseAlert.svelte';
import { shouldShowNotification } from '$lib/helpers/notifications';
import { isCloud } from '$lib/system';
import { currentPlan } from '$lib/stores/organization';
export const database = derived(page, ($page) => $page.data?.database as Models.Database);
@@ -17,16 +19,19 @@ export async function checkForDatabaseBackupPolicies(database: Models.Database)
if (!shouldShowNotification(backupsBannerId)) return;
let total = 0;
const backupsEnabled = get(currentPlan)?.backupsEnabled ?? true;
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
if (isCloud && backupsEnabled) {
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
}
}
showPolicyAlert.set(total <= 0);
+22 -7
View File
@@ -115,10 +115,26 @@ function createFeedbackStore() {
email?: string,
// eslint-disable-next-line
billingPlan?: string,
value?: number
value?: number,
orgId?: string,
projectId?: string,
userId?: string
) => {
if (!VARS.GROWTH_ENDPOINT) return;
trackEvent(Submit.FeedbackSubmit);
const customFields: Array<{ id: string; value: string | number }> = [
{ id: '47364', value: currentPage }
];
if (value) {
customFields.push({ id: '40655', value });
}
if (billingPlan) {
customFields.push({ id: '56109', value: billingPlan });
}
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/feedback`, {
method: 'POST',
headers: {
@@ -128,14 +144,13 @@ function createFeedbackStore() {
subject,
message,
email,
// billingPlan,
customFields,
firstname: name || 'Unknown',
customFields: [
{ id: '47364', value: currentPage },
...(value ? [{ id: '40655', value }] : [])
],
metaFields: {
source: get(feedback).source
source: get(feedback).source,
orgId,
projectId,
userId
}
})
});
+6
View File
@@ -101,6 +101,12 @@ export const oAuthProviders: Record<string, Provider> = {
docs: 'https://developers.facebook.com/',
component: Main
},
figma: {
name: 'Figma',
icon: 'figma',
docs: 'https://www.figma.com/developers/api#access-tokens',
component: Main
},
github: {
name: 'GitHub',
icon: 'github',
+14
View File
@@ -4,6 +4,15 @@ import type { Models } from '@appwrite.io/console';
import type { Tier } from './billing';
import type { Plan } from '$lib/sdk/billing';
export type OrganizationError = {
status: number;
message: string;
teamId: string;
invoiceId: string;
clientSecret: string;
type: string;
};
export type Organization = Models.Team<Record<string, unknown>> & {
billingBudget: number;
billingPlan: Tier;
@@ -21,6 +30,10 @@ export type Organization = Models.Team<Record<string, unknown>> & {
amount: number;
billingTaxId?: string;
billingPlanDowngrade?: Tier;
billingAggregationId: string;
billingInvoiceId: string;
status: string;
remarks: string;
};
export type OrganizationList = {
@@ -34,6 +47,7 @@ export type BillingLimits = {
executions: number;
storage: number;
users: number;
budgetLimit: number;
};
export const newOrgModal = writable<boolean>(false);
+4 -1
View File
@@ -8,7 +8,10 @@
<Container>
<div>
<Typography.Title size="xl">{page.error.status || 'Invalid Argument'}</Typography.Title>
<Typography.Title size="xl"
>{'status' in page.error
? page.error.status || 'Invalid Argument'
: 'Invalid Argument'}</Typography.Title>
<Typography.Title>{page.error.message}</Typography.Title>
</div>
<div>
@@ -14,10 +14,10 @@
<svelte:fragment slot="aside">
<BoxAvatar>
<svelte:fragment slot="image">
<AvatarInitials size="l" name={$user.name} />
<AvatarInitials size="m" name={$user.name || $user.email} />
</svelte:fragment>
<svelte:fragment slot="title">
<span class="u-bold u-trim-1" data-private>{$user.name}</span>
<span class="u-bold u-trim-1" data-private>{$user.name || 'User'}</span>
</svelte:fragment>
</BoxAvatar>
</svelte:fragment>
@@ -22,10 +22,6 @@
onMount(async () => {
const countryList = await sdk.forProject.locale.listCountries();
const locale = await sdk.forProject.locale.get();
if (locale.countryCode) {
selectedAddress.country = locale.countryCode;
}
options = countryList.countries.map((country) => {
return {
value: country.code,
@@ -57,6 +53,21 @@
trackError(e, Submit.BillingAddressUpdate);
}
}
/**
* This component isn't a modal, so it mounts with its parent.
* On mount, `selectedAddress` can be `null`, so we set the country when the modal shows.
*
* And we only run this if the selected address has no country set,
* which really shouldn't happen, but here we are, playing it safe!
*/
$: if (show && !selectedAddress.country) {
sdk.forProject.locale.get().then((locale) => {
if (locale.countryCode) {
selectedAddress.country = locale.countryCode;
}
});
}
</script>
<Modal bind:error bind:show onSubmit={handleSubmit} size="m" title="Update billing address">
@@ -12,7 +12,7 @@
let name: string = null;
onMount(async () => {
name ??= $user.name;
name ??= $user.name ?? '';
});
async function updateName() {
+103 -54
View File
@@ -3,25 +3,27 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
CreditsApplied,
EstimatedTotalBox,
SelectPaymentMethod
} from '$lib/components/billing';
import { CreditsApplied, EstimatedTotal, SelectPaymentMethod } from '$lib/components/billing';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputText } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { Wizard } from '$lib/layout';
import { type PaymentList } from '$lib/sdk/billing';
import { type PaymentList, type Plan } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import { organizationList, type Organization } from '$lib/stores/organization';
import {
organizationList,
type Organization,
type OrganizationError
} from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe.js';
import { ID } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { plansInfo } from '$lib/stores/billing';
import { isOrganization, plansInfo, type Tier } from '$lib/stores/billing';
import { Fieldset, Icon, Layout, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
export let data;
@@ -66,13 +68,19 @@
let coupon: string;
let couponData = data?.couponData;
let campaign = data?.campaign;
let billingPlan = BillingPlan.PRO;
let billingPlan: Tier = BillingPlan.PRO;
let tempOrgId = null;
let currentPlan: Plan;
$: onlyNewOrgs = (campaign && campaign.onlyNewOrgs) || (couponData && couponData.onlyNewOrgs);
$: selectedOrgId = tempOrgId;
function isUpgrade() {
const newPlan = page.data.plansInfo.get(billingPlan);
return currentPlan && newPlan && currentPlan.order < newPlan.order;
}
onMount(async () => {
await loadPaymentMethods();
if (!$organizationList?.total || campaign?.onlyNewOrgs) {
@@ -89,6 +97,19 @@
if ($organizationList.total > 0 && tempOrgId === null) {
tempOrgId = $organizationList.teams[0].$id;
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
collaborators = page.url.searchParams.get('invites').split(',');
await sdk.forConsole.billing.validateOrganization(organizationId, collaborators);
}
}
if (!selectedOrgId && $organizationList?.total) {
selectedOrgId = $organizationList.teams[0].$id;
}
});
async function loadPaymentMethods() {
@@ -103,76 +124,82 @@
async function handleSubmit() {
if (!couponForm.checkValidity()) return;
isSubmitting.set(true);
try {
let org: Organization;
let org: Organization | OrganizationError;
// Create new org
if (selectedOrgId === newOrgId) {
org = await sdk.forConsole.billing.createOrganization(
newOrgId,
name,
billingPlan,
paymentMethodId
paymentMethodId,
null,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
taxId
);
}
// Upgrade existing org
else if (selectedOrg?.billingPlan !== billingPlan) {
else if (selectedOrg?.billingPlan !== billingPlan && isUpgrade()) {
org = await sdk.forConsole.billing.updatePlan(
selectedOrg.$id,
billingPlan,
paymentMethodId,
null
null,
couponData.code ? couponData.code : null,
collaborators
);
}
// Existing pro org
// Existing pro org, apply credits
else {
org = selectedOrg;
}
// Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
}
// Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
if (!isOrganization(org) && org.status === 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('org', org.teamId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/apply-credit?' + params.toString()
);
org = await sdk.forConsole.billing.validateOrganization(org.teamId, collaborators);
}
// Add collaborators
if (collaborators?.length) {
collaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['owner'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
if (isOrganization(org)) {
trackEvent(Submit.CreditRedeem, {
coupon: couponData.code,
campaign: couponData?.campaign
});
await invalidate(Dependencies.ORGANIZATION);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
await invalidate(Dependencies.ACCOUNT);
}
// Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
}
trackEvent(Submit.CreditRedeem, {
coupon: couponData.code,
campaign: couponData?.campaign
});
await invalidate(Dependencies.ORGANIZATION);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
await invalidate(Dependencies.ACCOUNT);
} catch (e) {
trackError(e, Submit.CreditRedeem);
addNotification({
type: 'error',
message: e.message
});
} finally {
isSubmitting.set(false);
}
}
@@ -197,10 +224,32 @@
(team) => team.$id === selectedOrgId
) as Organization;
$: billingPlan =
selectedOrg?.billingPlan === BillingPlan.SCALE
? BillingPlan.SCALE
: (campaign?.plan ?? BillingPlan.PRO);
function getBillingPlan(): Tier | undefined {
const campaignPlan =
campaign?.plan && $plansInfo.get(campaign.plan) ? $plansInfo.get(campaign.plan) : null;
const orgPlan =
selectedOrg?.billingPlan && $plansInfo.get(selectedOrg.billingPlan)
? $plansInfo.get(selectedOrg.billingPlan)
: null;
if (!campaignPlan || !orgPlan) {
return;
}
return campaignPlan.order > orgPlan.order ? campaign.plan : selectedOrg?.billingPlan;
}
$: if (selectedOrg) {
billingPlan = getBillingPlan();
}
$: {
if (selectedOrgId) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
})();
}
}
</script>
<svelte:head>
@@ -249,7 +298,7 @@
</Layout.Stack>
</Fieldset>
{#if (selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)) || (!data?.couponData?.code && selectedOrgId)}
<Fieldset legend="Billing">
<Fieldset legend="Payment">
<Layout.Stack gap="xl">
{#if selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)}
<SelectPaymentMethod
@@ -286,7 +335,7 @@
</Form>
</Layout.Stack>
<svelte:fragment slot="aside">
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE && selectedOrg?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
{#if selectedOrg?.$id && selectedOrg?.billingPlan === billingPlan}
<section
class="card"
style:--p-card-padding="1.5rem"
+2 -3
View File
@@ -3,16 +3,15 @@ import type { Coupon } from '$lib/sdk/billing.js';
import type { Campaign } from '$lib/stores/campaigns.js';
import { sdk } from '$lib/stores/sdk.js';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
export const load = async ({ url }) => {
// Has promo code
if (url.searchParams.has('code')) {
let couponData: Coupon;
let campaign: Campaign;
const code = url.searchParams.get('code');
try {
couponData = await sdk.forConsole.billing.getCoupon(code);
couponData = await sdk.forConsole.billing.getCouponAccount(code);
if (couponData.campaign) {
campaign = await sdk.forConsole.billing.getCampaign(couponData.campaign);
}
@@ -4,24 +4,28 @@
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
EstimatedTotalBox,
PlanComparisonBox,
PlanSelection,
SelectPaymentMethod
SelectPaymentMethod,
SelectPlan
} from '$lib/components/billing';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, InputTags, InputText } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import type { Coupon } from '$lib/sdk/billing';
import { tierToPlan } from '$lib/stores/billing';
import type { Coupon, PaymentList } from '$lib/sdk/billing';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import type { Organization } from '$lib/stores/organization';
import { type OrganizationError, type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { ID } from '@appwrite.io/console';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { Divider, Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
import { onMount } from 'svelte';
export let data;
@@ -29,13 +33,22 @@
let selectedCoupon: Partial<Coupon> | null = data.coupon;
let previousPage: string = base;
let showExitModal = false;
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let name: string;
let paymentMethodId: string =
data.paymentMethods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
let billingPlan: BillingPlan = BillingPlan.FREE;
let paymentMethodId: string;
let collaborators: string[] = [];
let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
let taxId: string;
let billingBudget: number;
let showCreditModal = false;
@@ -43,9 +56,73 @@
previousPage = from?.url?.pathname || previousPage;
});
onMount(async () => {
if (page.url.searchParams.has('coupon')) {
const coupon = page.url.searchParams.get('coupon');
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
if (page.url.searchParams.has('name')) {
name = page.url.searchParams.get('name');
}
if (page.url.searchParams.has('plan')) {
const plan = page.url.searchParams.get('plan');
if (plan && Object.values(BillingPlan).includes(plan as BillingPlan)) {
billingPlan = plan as BillingPlan;
}
}
if (
data?.hasFreeOrganizations ||
(page.url.searchParams.has('type') && page.url.searchParams.get('type') === 'createPro')
) {
billingPlan = BillingPlan.PRO;
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
const invites = page.url.searchParams.get('invites').split(',');
await validate(organizationId, invites);
}
}
});
async function loadPaymentMethods() {
methods = await sdk.forConsole.billing.listPaymentMethods();
paymentMethodId = methods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
}
async function validate(organizationId: string, invites: string[]) {
try {
const org = await sdk.forConsole.billing.validateOrganization(organizationId, invites);
if (isOrganization(org)) {
await preloadData(`${base}/console/organization-${org.$id}`);
await goto(`${base}/console/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
}
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
trackError(e, Submit.OrganizationCreate);
}
}
async function create() {
try {
let org: Organization;
let org: Organization | OrganizationError;
if (selectedPlan === BillingPlan.FREE) {
org = await sdk.forConsole.billing.createOrganization(
@@ -61,53 +138,49 @@
name,
selectedPlan,
paymentMethodId,
null
null,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
taxId
);
//Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
}
//Add coupon
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
//Add collaborators
if (collaborators?.length) {
collaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['developer'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
});
}
// Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
if (!isOrganization(org) && org.status === 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('id', org.teamId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/create-organization?' + params.toString()
);
await validate(org.teamId, collaborators);
}
}
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(selectedPlan)?.name,
budget_cap_enabled: !!billingBudget,
plan: tierToPlan(billingPlan)?.name,
budget_cap_enabled: billingBudget !== null,
members_invited: collaborators?.length
});
await invalidate(Dependencies.ACCOUNT);
await preloadData(`${base}/organization-${org.$id}`);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${name ?? 'Organization'} has been created`
});
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await preloadData(`${base}/organization-${org.$id}`);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
}
} catch (e) {
addNotification({
type: 'error',
@@ -148,10 +221,22 @@
isNewOrg />
</Fieldset>
{#if selectedPlan !== BillingPlan.FREE}
<SelectPaymentMethod
methods={data.paymentMethods}
bind:value={paymentMethodId}
bind:taxId />
<Fieldset legend="Payment">
<SelectPaymentMethod
methods={data.paymentMethods}
bind:value={paymentMethodId}
bind:taxId>
<svelte:fragment slot="actions">
{#if !selectedCoupon?.code}
<Divider vertical style="height: 2rem;" />
<Button compact on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</svelte:fragment>
</SelectPaymentMethod>
</Fieldset>
<Fieldset legend="Invite members">
<InputTags
bind:tags={collaborators}
@@ -159,12 +244,6 @@
placeholder="Enter email address(es)"
id="members" />
</Fieldset>
{#if !selectedCoupon?.code}
<Button text on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
{/if}
</Layout.Stack>
</Form>
@@ -27,7 +27,7 @@ export const load: LayoutLoad = async ({ params, depends }) => {
const res = await sdk.forConsole.billing.getRoles(params.organization);
roles = res.roles;
scopes = res.scopes;
currentPlan = await sdk.forConsole.billing.getPlan(params.organization);
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(params.organization);
if (scopes.includes('billing.read')) {
await failedInvoice.load(params.organization);
if (get(failedInvoice)) {
@@ -24,7 +24,6 @@
import { ID, Region } from '@appwrite.io/console';
import { openImportWizard } from '../project-[project]/settings/migrations/(import)';
import { readOnly } from '$lib/stores/billing';
import type { RegionList } from '$lib/sdk/billing';
import { onMount, type ComponentType } from 'svelte';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
@@ -48,14 +47,15 @@
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { regions as regionsStore } from '$routes/(console)/organization-[organization]/store';
import type { Column } from '@appwrite.io/pink-svelte/dist/table';
import { timeFromNow } from '$lib/helpers/date';
export let data;
let addOrganization = false;
let showCreate = false;
let showCreateProjectCloud = false;
let addOrganization = false;
async function allServiceDisabled(project: Models.Project) {
let disabled = true;
@@ -140,21 +140,18 @@
}
};
let regions: RegionList;
onMount(async () => {
if (isCloud) {
regions = await sdk.forConsole.billing.listRegions();
const regions = await sdk.forConsole.billing.listRegions();
regionsStore.set(regions);
checkPricingRefAndRedirect(page.url.searchParams);
}
const searchParams = page.url.searchParams;
if (searchParams.has('create-project')) {
handleCreateProject();
}
});
function findRegion(project: Models.Project) {
return regions.regions.find((region) => region.$id === project.region);
return $regionsStore?.regions?.find(
(region) => region.$id === (project as Models.Project & { region: string }).region
);
}
const columns: Column[] = [{ id: 'name' }, { id: 'updated' }];
@@ -243,41 +240,39 @@
{/if}
{/await}
{#each platforms as platform, i}
{#if i < 3}
{@const icon = getIconForPlatform(platform.icon)}
<Badge variant="secondary" content={platform.name}>
<Icon {icon} size="s" slot="start" />
</Badge>
{/if}
{/each}
{#if platforms?.length > 3}
<Badge
variant="secondary"
content={`+${project.platforms.length - 3}`} />
{#each platforms as platform, i}
{#if i < 3}
{@const icon = getIconForPlatform(platform.icon)}
<Badge variant="secondary" content={platform.name}>
<Icon {icon} size="s" slot="start" />
</Badge>
{/if}
<svelte:fragment slot="icons">
{#if isCloud && regions}
{@const region = findRegion(project)}
<span class="u-color-text-gray u-medium u-line-height-2">
{region?.name}
</span>
{/if}
</svelte:fragment>
</GridItem1>
{/each}
<svelte:fragment slot="empty">
<p>Create a new project</p>
</svelte:fragment>
</CardContainer>
{:else}
<Empty
single
allowCreate={$canWriteProjects}
on:click={handleCreateProject}
target="project"
href="https://appwrite.io/docs/quick-starts"></Empty>
{/if}
{/each}
{#if platforms?.length > 3}
<Badge variant="secondary" content={`+${project.platforms.length - 3}`} />
{/if}
<svelte:fragment slot="icons">
{#if isCloud && $regionsStore?.regions}
{@const region = findRegion(project)}
<span class="u-color-text-gray u-medium u-line-height-2">
{region?.name}
</span>
{/if}
</svelte:fragment>
</GridItem1>
{/each}
<svelte:fragment slot="empty">
<p>Create a new project</p>
</svelte:fragment>
</CardContainer>
{:else}
<Empty
single
allowCreate={$canWriteProjects}
on:click={handleCreateProject}
target="project"
href="https://appwrite.io/docs/quick-starts"></Empty>
{/if}
<PaginationWithLimit
name="Projects"
@@ -290,5 +285,5 @@
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
{#if showCreateProjectCloud}
<CreateProjectCloud bind:showCreateProjectCloud regions={regions.regions} />
<CreateProjectCloud bind:showCreateProjectCloud regions={$regionsStore.regions} />
{/if}
@@ -1,7 +1,6 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { currentPlan, organization } from '$lib/stores/organization';
import BudgetAlert from './budgetAlert.svelte';
import { organization } from '$lib/stores/organization';
import BudgetCap from './budgetCap.svelte';
import PlanSummary from './planSummary.svelte';
import BillingAddress from './billingAddress.svelte';
@@ -19,8 +18,10 @@
import RetryPaymentModal from './retryPaymentModal.svelte';
import { selectedInvoice, showRetryModal } from './store';
import { Button } from '$lib/elements/forms';
import { goto } from '$app/navigation';
import { Alert, Typography } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { base } from '$app/paths';
export let data;
@@ -51,10 +52,21 @@
await confirmPayment(
$organization.$id,
invoice.clientSecret,
$organization.paymentMethodId
$organization.paymentMethodId,
`${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
);
}
if (
page.url.searchParams.has('type') &&
page.url.searchParams.get('type') === 'validate-invoice'
) {
const invoiceId = page.url.searchParams.get('invoice');
await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoiceId);
invalidate(Dependencies.INVOICES);
invalidate(Dependencies.ORGANIZATION);
}
if (
page.url.searchParams.has('invoice') &&
page.url.searchParams.get('type') === 'retry'
@@ -108,24 +120,22 @@
{/if}
{#if $organization?.billingPlanDowngrade}
<Alert.Inline status="info">
Your organization will change to a {tierToPlan($organization?.billingPlanDowngrade)
.name} plan once your current billing cycle ends and your invoice is paid on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
Your organization has changed to {tierToPlan($organization?.billingPlanDowngrade).name} plan.
You will continue to have access to {tierToPlan($organization?.billingPlan).name} plan features
until your billing period ends on {toLocaleDate($organization.billingNextInvoiceDate)}.
</Alert.Inline>
{/if}
<Typography.Title>Billing</Typography.Title>
<PlanSummary
creditList={data?.creditList}
members={data?.members}
currentPlan={$currentPlan}
invoices={data?.invoices.invoices} />
currentPlan={data?.aggregationBillingPlan}
currentAggregation={data?.billingAggregation}
currentInvoice={data?.billingInvoice} />
<PaymentHistory />
<PaymentMethods />
<BillingAddress billingAddress={data?.billingAddress} />
<TaxId />
<BudgetCap />
<BudgetAlert />
<AvailableCredit />
</Container>
@@ -26,25 +26,47 @@ export const load: PageLoad = async ({ parent, depends }) => {
.catch(() => null)
: null;
const [paymentMethods, addressList, aggregationList, billingAddress, creditList, invoices] =
/**
* Needed to keep this out of Promise.all, as when organization is
* initially created, these might return 404
* - can be removed later once that is fixed in back-end
*/
let billingAggregation = null;
try {
billingAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
(organization as Organization)?.billingAggregationId
);
} catch (e) {
// ignore error
}
let billingInvoice = null;
try {
billingInvoice = await sdk.forConsole.billing.getInvoice(
organization.$id,
(organization as Organization)?.billingInvoiceId
);
} catch (e) {
// ignore error
}
const [paymentMethods, addressList, billingAddress, creditList, aggregationBillingPlan] =
await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
sdk.forConsole.billing.listAggregation(organization.$id),
billingAddressPromise,
sdk.forConsole.billing.listCredits(organization.$id),
sdk.forConsole.billing.listInvoices(organization.$id, [
Query.limit(1),
Query.equal('from', organization.billingCurrentInvoiceDate)
])
sdk.forConsole.billing.getPlan(billingAggregation?.plan ?? organization.billingPlan)
]);
return {
paymentMethods,
addressList,
aggregationList,
billingAddress,
aggregationBillingPlan,
creditList,
invoices
billingAggregation,
billingInvoice
};
};
@@ -158,7 +158,7 @@
{#if creditList?.total > limit}
<div class="u-flex u-main-space-between">
<p class="text">Total credits: {creditList?.total}</p>
<PaginationInline {limit} bind:offset sum={creditList?.total} hidePages />
<PaginationInline {limit} bind:offset total={creditList?.total} hidePages />
</div>
{/if}
{:else}
@@ -14,6 +14,8 @@
import { IconTrash } from '@appwrite.io/pink-icons-svelte';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
export let alertsEnabled = false;
let search: string;
let selectedAlert: number;
let alerts: number[] = [];
@@ -72,7 +74,8 @@
}
}
$: isButtonDisabled = symmetricDifference(alerts, $organization.budgetAlerts).length === 0;
$: isButtonDisabled =
symmetricDifference(alerts, $organization.budgetAlerts).length === 0 || !alertsEnabled;
</script>
<CardGrid>
@@ -91,6 +94,11 @@
Upgrade to a Pro plan to manage when you receive billing alerts for your
organization.
</Alert.Inline>
{:else if !$currentPlan.budgetCapEnabled}
<Alert.Inline status="info" title="Budget cap disabled">
Budget caps are not supported on your current plan. For more information, please
reach out to your customer success manager.
</Alert.Inline>
{:else}
{#if alerts.length >= 4}
<Alert.Inline status="info">
@@ -10,13 +10,14 @@
import { sdk } from '$lib/stores/sdk';
import { Alert, Link } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import BudgetAlert from './budgetAlert.svelte';
let capActive = false;
let budget: number;
onMount(() => {
budget = $organization?.billingBudget;
capActive = !!$organization?.billingBudget;
capActive = $organization?.billingBudget !== null;
});
async function updateBudget() {
@@ -26,7 +27,7 @@
budget,
$organization.budgetAlerts
);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
isHtml: true,
@@ -45,7 +46,7 @@
}
$: if (!capActive) {
budget = 0;
budget = null;
}
</script>
@@ -64,6 +65,11 @@
target="_blank"
rel="noopener noreferrer">view our pricing guide.</Link.Anchor>
</Alert.Inline>
{:else if !$currentPlan.budgetCapEnabled}
<Alert.Inline status="info" title="Budget cap disabled">
Budget caps are not supported on your current plan. For more information, please
reach out to your customer success manager.
</Alert.Inline>
{:else}
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
@@ -105,3 +111,5 @@
</svelte:fragment>
</CardGrid>
</Form>
<BudgetAlert alertsEnabled={capActive && budget > 0} />
@@ -0,0 +1,42 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { organization } from '$lib/stores/organization';
import { Dependencies } from '$lib/constants';
import { invalidate } from '$app/navigation';
import { tierToPlan } from '$lib/stores/billing';
import { toLocaleDate } from '$lib/helpers/date';
export let showCancel = false;
let error: string = null;
async function cancelDowngrade() {
try {
await sdk.forConsole.billing.cancelDowngrade($organization.$id);
await invalidate(Dependencies.ORGANIZATION);
showCancel = false;
addNotification({
type: 'success',
message: `${$organization.name} plan change has been cancelled.`
});
} catch (e) {
error = e.message;
}
}
</script>
<Modal title="Cancel plan change" onSubmit={cancelDowngrade} bind:show={showCancel} bind:error>
<p>
Your organization is set to change to <strong>
{tierToPlan($organization?.billingPlanDowngrade).name}</strong>
plan on <strong> {toLocaleDate($organization.billingNextInvoiceDate)}</strong>. Are you sure
you want to cancel this change and keep your current plan?
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showCancel = false)}>Keep change</Button>
<Button secondary submit>Cancel change</Button>
</svelte:fragment>
</Modal>
@@ -5,7 +5,7 @@
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Invoice, InvoiceList } from '$lib/sdk/billing';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
@@ -28,8 +28,7 @@
IconExternalLink,
IconRefresh
} from '@appwrite.io/pink-icons-svelte';
// let isLoadingInvoices = true;
import { base } from '$app/paths';
let offset = 0;
let invoiceList: InvoiceList = {
@@ -38,7 +37,6 @@
};
const limit = 5;
const endpoint = getApiEndpoint();
onMount(request);
@@ -47,11 +45,13 @@
invoiceList = await sdk.forConsole.billing.listInvoices(page.params.organization, [
Query.limit(limit),
Query.offset(offset),
Query.notEqual('from', $organization.billingCurrentInvoiceDate),
Query.notEqual('status', 'pending'),
Query.orderDesc('$createdAt')
]);
// isLoadingInvoices = false;
}
$: if (page.url.searchParams.get('type') === 'validate-invoice') {
window.history.replaceState({}, '', page.url.pathname);
request();
}
function retryPayment(invoice: Invoice) {
@@ -135,12 +135,12 @@
<ActionMenu.Item.Anchor
leadingIcon={IconExternalLink}
external
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
View invoice
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Anchor
leadingIcon={IconDownload}
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
Download PDF
</ActionMenu.Item.Anchor>
{#if status === 'overdue' || status === 'failed'}
@@ -165,7 +165,7 @@
{#if invoiceList.total > limit}
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList.total}</p>
<PaginationInline {limit} bind:offset sum={invoiceList.total} hidePages />
<PaginationInline {limit} bind:offset total={invoiceList.total} hidePages />
</div>
{/if}
{:else}
@@ -3,14 +3,13 @@
import { CardGrid } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { plansInfo, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import type { CreditList, Invoice, Plan } from '$lib/sdk/billing';
import type { Aggregation, CreditList, Invoice, Plan } from '$lib/sdk/billing';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import { type Models } from '@appwrite.io/console';
import {
Accordion,
Card,
@@ -21,14 +20,15 @@
Typography
} from '@appwrite.io/pink-svelte';
import { IconInfo, IconTag } from '@appwrite.io/pink-icons-svelte';
import CancelDowngradeModel from './cancelDowngradeModal.svelte';
export let invoices: Array<Invoice>;
export let members: Models.MembershipList;
export let currentPlan: Plan;
export let creditList: CreditList;
export let currentInvoice: Invoice | undefined = undefined;
export let currentAggregation: Aggregation | undefined = undefined;
let showCancel: boolean = false;
const currentInvoice: Invoice | undefined = invoices.length > 0 ? invoices[0] : undefined;
const extraMembers = members.total > 1 ? members.total - 1 : 0;
const availableCredit = creditList.available;
const today = new Date();
const isTrial =
@@ -60,15 +60,13 @@
exclude accumulated credits and applicable taxes.
<svelte:fragment slot="aside">
<p class="text u-bold">
Billing period: {toLocaleDate($organization?.billingCurrentInvoiceDate)} - {toLocaleDate(
$organization?.billingNextInvoiceDate
)}
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Card.Base variant="secondary" padding="s">
<Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{tierToPlan($organization?.billingPlan)?.name} plan
{currentPlan.name} plan
</Typography.Text>
<Typography.Text>
{isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
@@ -79,36 +77,40 @@
</Typography.Text>
</Layout.Stack>
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION && extraUsage > 0}
{#if currentPlan.budgeting && extraUsage > 0}
<Accordion
hideDivider
title="Add-ons"
badge={(extraMembers ? extraAddons + 1 : extraAddons).toString()}>
badge={(currentAggregation.additionalMembers > 0
? currentInvoice.usage.length + 1
: currentInvoice.usage.length
).toString()}>
<svelte:fragment slot="end">
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</svelte:fragment>
<Layout.Stack gap="xs">
{#if extraMembers}
{#if currentAggregation.additionalMembers}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary"
>Additional members</Typography.Text>
<Typography.Text
>{formatCurrency(
extraMembers *
(currentPlan?.addons?.member?.price ?? 0)
)}</Typography.Text>
<Typography.Text>
{formatCurrency(
currentAggregation.additionalMemberAmount
)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Typography.Text>{extraMembers}</Typography.Text>
<Typography.Text
>{currentAggregation.additionalMembers}</Typography.Text>
</Layout.Stack>
</Layout.Stack>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
{#if i > 0 || extraMembers}
{#if i > 0 || currentAggregation.additionalMembers}
<Divider />
{/if}
{#if ['storage', 'bandwidth'].includes(excess.name)}
@@ -180,7 +182,7 @@
</Accordion>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && availableCredit > 0}
{#if currentPlan.supportsCredits && availableCredit > 0}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" gap="xxs">
<Icon size="s" icon={IconTag} color="--fgcolor-success" />
@@ -188,7 +190,7 @@
>Credits to be applied</Typography.Text>
</Layout.Stack>
<Typography.Text color="--fgcolor-success">
{formatCurrency(
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</Typography.Text>
@@ -245,17 +247,21 @@
{:else}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
<Button
text
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
from: 'button',
source: 'billing_tab'
})}>
Change plan
</Button>
{#if $organization?.billingPlanDowngrade !== null}
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
{:else}
<Button
text
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
from: 'button',
source: 'billing_tab'
})}>
Change plan
</Button>
{/if}
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
@@ -265,6 +271,8 @@
</CardGrid>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
--billing-card-border-color: hsl(var(--color-neutral-10));
@@ -15,6 +15,7 @@
import { onMount } from 'svelte';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { formatCurrency } from '$lib/helpers/numbers';
import { base } from '$app/paths';
export let show = false;
export let invoice: Invoice;
@@ -74,8 +75,12 @@
await confirmPayment(
$organization.$id,
clientSecret,
paymentMethodId ? paymentMethodId : $organization.paymentMethodId
paymentMethodId ? paymentMethodId : $organization.paymentMethodId,
`${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
);
await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoice.$id);
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.INVOICES);
@@ -111,9 +116,9 @@
title="Retry payment">
<!-- TODO: format currency -->
<p class="text">
Your payment of <span class="inline-tag">${formatCurrency(invoice.grossAmount)}</span> due
on {toLocaleDate(invoice.dueAt)} has failed. Retry your payment to avoid service interruptions
with your projects.
Your payment of <span class="inline-tag">{formatCurrency(invoice.grossAmount)}</span> due on {toLocaleDate(
invoice.dueAt
)} has failed. Retry your payment to avoid service interruptions with your projects.
</p>
<Button
@@ -17,7 +17,7 @@
async function validateCoupon() {
if (couponData?.status === 'active') return;
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
$addCreditWizardStore.coupon = coupon;
coupon = null;
@@ -0,0 +1,28 @@
<script lang="ts">
import { page } from '$app/stores';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, readOnly, showBudgetAlert } from '$lib/stores/billing';
import { base } from '$app/paths';
$: redirectUrl = `${base}/organization-${$organization.$id}/billing#update-budget`;
</script>
{#if $showBudgetAlert && $organization?.$id && $organization?.billingPlan !== BillingPlan.FREE && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert type="error" title="Budget limit reached">
<svelte:fragment>
This organization has reached its budget limit and is now blocked. To continue using
Appwrite services, update the budget limit.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
<span class="text">View usage</span>
</Button>
<Button secondary fullWidthMobile href={redirectUrl}>
<span class="text">Update limit</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -3,28 +3,34 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
EstimatedTotalBox,
PlanComparisonBox,
SelectPaymentMethod
} from '$lib/components/billing';
import { PlanComparisonBox, PlanSelection, SelectPaymentMethod } from '$lib/components/billing';
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import PlanSelection from '$lib/components/billing/planSelection.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputTextarea } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers.js';
import { Wizard } from '$lib/layout';
import { type Coupon } from '$lib/sdk/billing';
import { plansInfo, tierToPlan } from '$lib/stores/billing';
import { type Coupon, type PaymentList } from '$lib/sdk/billing';
import { isOrganization, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { currentPlan, organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Alert, Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import {
Alert,
Divider,
Fieldset,
Icon,
Layout,
Link,
Typography
} from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
export let data;
@@ -48,10 +54,46 @@
let showCreditModal = false;
let feedbackDowngradeReason: string;
let feedbackMessage: string;
let couponData: Partial<Coupon> = data.coupon;
afterNavigate(({ from }) => {
previousPage = from?.url?.pathname || previousPage;
});
onMount(async () => {
if (page.url.searchParams.has('code')) {
const coupon = page.url.searchParams.get('code');
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
if (page.url.searchParams.has('plan')) {
const plan = page.url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
selectedPlan = plan as BillingPlan;
}
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
const invites = page.url.searchParams.get('invites').split(',');
await validate(organizationId, invites);
}
}
if ($currentPlan?.$id === BillingPlan.SCALE) {
selectedPlan = BillingPlan.SCALE;
} else {
selectedPlan = BillingPlan.PRO;
}
});
async function handleSubmit() {
if (isDowngrade) {
@@ -88,14 +130,13 @@
})
});
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
isHtml: true,
message: `
<b>${$organization.name}</b> will change to ${
tierToPlan(selectedPlan).name
} plan at the end of the current billing cycle.`
message: `<b>${$organization.name}</b> plan has been successfully updated.`
});
trackEvent(Submit.OrganizationDowngrade, {
@@ -110,61 +151,88 @@
}
}
async function validate(organizationId: string, invites: string[]) {
try {
let org = await sdk.forConsole.billing.validateOrganization(organizationId, invites);
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
}
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
trackError(e, Submit.OrganizationCreate);
}
}
async function upgrade() {
try {
//Add collaborators
let newCollaborators = [];
if (collaborators?.length) {
newCollaborators = collaborators.filter(
(collaborator) =>
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
);
}
const org = await sdk.forConsole.billing.updatePlan(
data.organization.$id,
selectedPlan,
paymentMethodId,
null
null,
couponData?.code,
newCollaborators,
billingBudget,
taxId ? taxId : null
);
//Add coupon
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
//Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
}
//Add collaborators
if (collaborators?.length) {
const newCollaborators = collaborators.filter(
(collaborator) =>
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
if (!isOrganization(org) && org.status == 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('type', 'payment_confirmed');
params.append('id', org.teamId);
params.append('invites', collaborators.join(','));
params.append('plan', selectedPlan);
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/change-plan?' + params.toString()
);
newCollaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['owner'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
await validate(org.teamId, collaborators);
}
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
}
//Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
}
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
} catch (e) {
addNotification({
type: 'error',
@@ -215,14 +283,12 @@
{#if isDowngrade}
{#if selectedPlan === BillingPlan.FREE}
<PlanExcess
tier={BillingPlan.FREE}
members={data?.members?.total ?? 0} />
<PlanExcess tier={BillingPlan.FREE} />
{:else if selectedPlan === BillingPlan.PRO && data.organization.billingPlan === BillingPlan.SCALE && collaborators?.length > 0}
{@const extraMembers = collaborators?.length ?? 0}
{@const price = formatCurrency(
extraMembers *
($plansInfo?.get(selectedPlan)?.addons?.member?.price ?? 0)
($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0)
)}
<Alert.Inline status="error">
<svelte:fragment slot="title">
@@ -240,10 +306,22 @@
<!-- Show email input if upgrading from free plan -->
{#if selectedPlan !== BillingPlan.FREE && data.organization.billingPlan === BillingPlan.FREE}
<SelectPaymentMethod
methods={data.paymentMethods}
bind:value={paymentMethodId}
bind:taxId />
<Fieldset legend="Payment">
<SelectPaymentMethod
methods={data.paymentMethods}
bind:value={paymentMethodId}
bind:taxId>
<svelte:fragment slot="actions">
{#if !selectedCoupon?.code}
<Divider vertical style="height: 2rem" />
<Button compact on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</svelte:fragment>
</SelectPaymentMethod>
</Fieldset>
<Fieldset legend="Invite members">
<InputTags
bind:tags={collaborators}
@@ -251,12 +329,6 @@
placeholder="Enter email address(es)"
id="members" />
</Fieldset>
{#if !selectedCoupon?.code}
<Button text on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
{/if}
{#if isDowngrade && selectedPlan === BillingPlan.FREE}
<Fieldset legend="Feedback">

Some files were not shown because too many files have changed in this diff Show More