Merge pull request #1746 from appwrite/sdk-context-main-merge

Merge `main` for `fix-sdk-context`
This commit is contained in:
Darshan
2025-03-25 17:52:27 +05:30
committed by GitHub
136 changed files with 2824 additions and 1213 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
@@ -24,12 +25,14 @@ ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
ARG PUBLIC_STRIPE_KEY
ARG SENTRY_AUTH_TOKEN
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 NODE_OPTIONS=--max_old_space_size=8192
RUN pnpm run sync && pnpm run build
@@ -39,4 +42,4 @@ FROM nginx:1.25-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
+1 -1
View File
@@ -25,7 +25,7 @@
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
"@ai-sdk/svelte": "^1.1.22",
"@ai-sdk/svelte": "^1.1.24",
"analytics": "^0.8.14",
"cron-parser": "^4.9.0",
"dayjs": "^1.11.13",
+1 -1
View File
@@ -12,7 +12,7 @@ const config: PlaywrightTestConfig = {
webServer: {
timeout: 120000,
env: {
PUBLIC_APPWRITE_ENDPOINT: 'https://console-testing-2.appwrite.org/v1',
PUBLIC_APPWRITE_ENDPOINT: 'https://dlbillingic.appwrite.org/v1',
PUBLIC_CONSOLE_MODE: 'cloud',
PUBLIC_STRIPE_KEY:
'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn'
+12 -12
View File
@@ -9,7 +9,7 @@ importers:
.:
dependencies:
'@ai-sdk/svelte':
specifier: ^1.1.22
specifier: ^1.1.24
version: 1.1.24(svelte@4.2.19)(zod@3.24.2)
'@appwrite.io/console':
specifier: 1.5.2
@@ -2712,13 +2712,13 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.10:
resolution: {integrity: sha512-vSJJTG+t/dIKAUhUDw/dLdZ9s//5OxcHqLaDWWrW4Cdq7o6tdLIczUkMXt2MBNmk6sJRZBZRXVixs7URY1CmIg==}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
nanoid@3.3.9:
resolution: {integrity: sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -3712,8 +3712,8 @@ packages:
resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
engines: {node: '>=12.20'}
zod-to-json-schema@3.24.4:
resolution: {integrity: sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==}
zod-to-json-schema@3.24.3:
resolution: {integrity: sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==}
peerDependencies:
zod: ^3.24.1
@@ -3731,7 +3731,7 @@ snapshots:
dependencies:
'@ai-sdk/provider': 1.0.11
eventsource-parser: 3.0.0
nanoid: 3.3.10
nanoid: 3.3.9
secure-json-parse: 2.7.0
optionalDependencies:
zod: 3.24.2
@@ -3753,7 +3753,7 @@ snapshots:
dependencies:
'@ai-sdk/provider': 1.0.11
'@ai-sdk/provider-utils': 2.1.13(zod@3.24.2)
zod-to-json-schema: 3.24.4(zod@3.24.2)
zod-to-json-schema: 3.24.3(zod@3.24.2)
optionalDependencies:
zod: 3.24.2
@@ -6769,10 +6769,10 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.10: {}
nanoid@3.3.7: {}
nanoid@3.3.9: {}
nanoid@5.0.8: {}
natural-compare@1.4.0: {}
@@ -7672,7 +7672,7 @@ snapshots:
yocto-queue@1.1.1: {}
zod-to-json-schema@3.24.4(zod@3.24.2):
zod-to-json-schema@3.24.3(zod@3.24.2):
dependencies:
zod: 3.24.2
+3
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' };
@@ -65,6 +66,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 {
-1
View File
@@ -13,7 +13,6 @@
{formatted}
series={series.map((s) => {
s.type = 'bar';
s.stack = 'total';
s.barMaxWidth = 6;
s.itemStyle = {
borderRadius: [10, 10, 0, 0]
+1
View File
@@ -1,2 +1,3 @@
export { default as BarChart } from './bar.svelte';
export { default as LineChart } from './line.svelte';
export { default as Legend, type LegendData } from './legend.svelte';
+25
View File
@@ -0,0 +1,25 @@
<script context="module" lang="ts">
export type LegendData = {
name: string;
value: string | number | boolean;
};
</script>
<script lang="ts">
import { Colors } from '$lib/charts/config';
import { Status } from '$lib/components';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
export let legendData: LegendData[] = [];
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}
<Status status="none" statusIconStyle="background-color: {colors[index % colors.length]}">
{name} ({formattedValue})
</Status>
{/each}
</div>
+2 -2
View File
@@ -180,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">
@@ -230,7 +230,7 @@
<div class="footer" slot="footer">
<div class="u-flex u-cross-center u-gap-4">
<AvatarInitials size={32} name={$user.name} />
<AvatarInitials size={32} name={$user.name || $user.email} />
<form
class="input-text-wrapper u-width-full-line"
style="--amount-of-buttons: 1;"
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { 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_credits_redeem', {
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}
@@ -17,7 +17,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;
@@ -0,0 +1,47 @@
<script lang="ts">
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
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>
{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
})}>
<span class="icon-x" aria-hidden="true" />
</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,146 @@
<script lang="ts">
import { FormList, 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" />
<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}
<FormList class="u-margin-block-start-24">
<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>
</FormList>
</Card>
{/if}
@@ -1,97 +0,0 @@
<script lang="ts">
import { FormList, InputChoice, InputNumber } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { plansInfo, type Tier } from '$lib/stores/billing';
import { CreditsApplied } from '.';
export let billingPlan: Tier;
export let collaborators: string[];
export let couponData: Partial<Coupon>;
export let billingBudget: number;
export let fixedCoupon = false; // If true, the coupon cannot be removed
export let isDowngrade = false;
const today = new Date();
const billingPayDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
let budgetEnabled = false;
$: currentPlan = $plansInfo.get(billingPlan);
$: extraSeatsCost = 0; // 0 untile trial period later replace (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: grossCost = currentPlan.price + extraSeatsCost;
$: estimatedTotal =
couponData?.status === 'active'
? grossCost - couponData.credits >= 0
? grossCost - couponData.credits
: 0
: grossCost;
$: trialEndDate = new Date(
billingPayDate.getTime() + currentPlan.trialDays * 24 * 60 * 60 * 1000
);
</script>
<section
class="card u-flex u-flex-vertical u-gap-8"
style:--p-card-padding="1.5rem"
style:--p-card-border-radius="var(--border-radius-small)">
<slot />
<span class="u-flex u-main-space-between">
<p class="text">{currentPlan.name} plan</p>
<p class="text">{formatCurrency(currentPlan.price)}</p>
</span>
<span class="u-flex u-main-space-between">
<p class="text" class:u-bold={isDowngrade}>Additional seats ({collaborators?.length})</p>
<p class="text" class:u-bold={isDowngrade}>
{formatCurrency(extraSeatsCost)}
</p>
</span>
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<div class="u-sep-block-start" />
<span class="u-flex u-main-space-between">
<p class="text">
Upcoming charge<br /><span class="u-color-text-gray"
>Due on {!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span>
</p>
<p class="text">
{formatCurrency(estimatedTotal)}
</p>
</span>
<p class="text u-margin-block-start-16">
You'll pay <span class="u-bold">{formatCurrency(estimatedTotal)}</span> now, with your first
billing cycle starting on
<span class="u-bold"
>{!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span
>. Once your credits run out, you'll be charged
<span class="u-bold">{formatCurrency(currentPlan.price)}</span> plus usage fees every 30 days.
</p>
<FormList class="u-margin-block-start-24">
<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>
</FormList>
</section>
+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';
@@ -1,7 +1,7 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatNum } from '$lib/helpers/string';
import { plansInfo, tierFree, tierPro, type Tier } from '$lib/stores/billing';
import { plansInfo, tierFree, tierPro, tierScale, type Tier } from '$lib/stores/billing';
import { Card, SecondaryTabs, SecondaryTabsItem } from '..';
let selectedTab: Tier = BillingPlan.FREE;
@@ -23,11 +23,11 @@
on:click={() => (selectedTab = BillingPlan.PRO)}>
{tierPro.name}
</SecondaryTabsItem>
<!-- <SecondaryTabsItem
<SecondaryTabsItem
disabled={selectedTab === BillingPlan.SCALE}
on:click={() => (selectedTab = BillingPlan.SCALE)}>
{tierScale.name}
</SecondaryTabsItem> -->
</SecondaryTabsItem>
</SecondaryTabs>
</div>
@@ -109,7 +109,7 @@
<p class="u-margin-block-start-8">Everything in the Pro plan, plus:</p>
<ul class="un-order-list u-margin-inline-start-4">
<li>Unlimited seats</li>
<li>Organization roles <span class="inline-tag">Coming soon</span></li>
<li>Organization roles</li>
<li>SOC-2, HIPAA compliance</li>
<li>SSO <span class="inline-tag">Coming soon</span></li>
<li>Priority support</li>
+26 -31
View File
@@ -12,18 +12,16 @@
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 { tooltip } from '$lib/actions/tooltip';
export let tier: Tier;
export let members: number;
const plan = $plansInfo?.get(tier);
let excess: {
@@ -33,43 +31,39 @@
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>
<Alert type="warning" {...$$restProps}>
<svelte:fragment slot="title">
Your organization will switch to {tierToPlan(BillingPlan.FREE).name} plan on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
</svelte:fragment>
{#if !showExcess}
You will retain access to your {tierToPlan($organization.billingPlan).name} plan features until
your billing period ends. After that, your organization will be limited to Free plan resources,
and service disruptions may occur if usage exceeds plan limits.
{:else}
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>
{/if} and service disruptions may occur if usage exceeds Free plan limits.
{/if}
</Alert>
{#if showExcess}
<Alert type="error" {...$$restProps}>
<svelte:fragment slot="title">
Your {tierToPlan($organization.billingPlan).name} plan subscription will end on {toLocaleDate(
$organization.billingNextInvoiceDate
)}
</svelte:fragment>
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>
</Alert>
<TableScroll noMargin dense class="u-margin-block-start-16">
<TableScroll dense class="u-margin-block-start-16">
<TableHeader>
<TableCellHead>Resource</TableCellHead>
<TableCellHead>Free limit</TableCellHead>
@@ -83,7 +77,8 @@
{#if excess?.members}
<TableRow>
<TableCellText title="members">Organization members</TableCellText>
<TableCellText title="limit">{plan.addons.seats.limit} members</TableCellText>
<TableCellText title="limit"
>{plan.addons.seats.limit || 0} members</TableCellText>
<TableCell title="excess">
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up" />
+27 -26
View File
@@ -76,31 +76,32 @@
</svelte:fragment>
</LabelCard>
</li>
{#if $organization?.billingPlan === BillingPlan.SCALE}
<li>
<LabelCard
name="plan"
bind:group={billingPlan}
value={BillingPlan.SCALE}
padding={1.5}>
<svelte:fragment slot="custom">
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
<h4 class="body-text-2 u-bold">
{tierScale.name}
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{tierScale.description}
</p>
<p>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</p>
</div>
</svelte:fragment>
</LabelCard>
</li>
{/if}
<li>
<LabelCard
name="plan"
bind:group={billingPlan}
value={BillingPlan.SCALE}
padding={1.5}
disabled={!selfService}>
<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">
{tierScale.name}
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{tierScale.description}
</p>
<p>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</p>
</div>
</svelte:fragment>
</LabelCard>
</li>
</ul>
{/if}
@@ -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
@@ -50,6 +50,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 size="big" headerDivider={false} title="Usage rates">
@@ -81,7 +88,7 @@
<TableRow>
<TableCellText title="resource">{usage.resource}</TableCellText>
<TableCellText title="limit">
{plan[usage.id] || 'Unlimited'}
{getCorrectSeatsCountValue(plan.addons.seats.limit)}
</TableCellText>
{#if !isFree}
<TableCellText title="rate">
@@ -90,7 +97,7 @@
{/if}
</TableRow>
{:else}
{@const addon = plan.addons[usage.id]}
{@const addon = plan.usage[usage.id]}
<TableRow>
<TableCellText title="resource">{usage.resource}</TableCellText>
<TableCellText title="limit">
@@ -18,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;
+1 -1
View File
@@ -56,7 +56,7 @@ export { default as Limit } from './limit.svelte';
export { default as PaginationWithLimit } from './paginationWithLimit.svelte';
export { default as ClickableList } from './clickableList.svelte';
export { default as ClickableListItem } from './clickableListItem.svelte';
export { default as Id, truncateText } from './id.svelte';
export { default as Id } from './id.svelte';
export * from './progressbar';
export { default as ProgressBarBig } from './progressBarBig.svelte';
export { default as CreditCardInfo } from './creditCardInfo.svelte';
+3 -1
View File
@@ -80,7 +80,9 @@
on:cancel|preventDefault
{style}>
{#if show}
<slot close={closeModal} />
<div class="content">
<slot close={closeModal} />
</div>
{/if}
</dialog>
+11 -7
View File
@@ -6,7 +6,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> = [];
@@ -14,20 +14,24 @@
</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">
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4">{currentValue}</span>
<span class="body-text-1 u-bold">{currentUnit}</span>
</p>
<p class="heading-level-4">{progress}%</p>
{#if progressMax !== undefined}
<p class="heading-level-4">{progress}%</p>
{/if}
</div>
<p class="body-text-2">
{maxValue}
{maxUnit ? maxUnit : ''}
</p>
{#if maxValue !== undefined}
<p class="body-text-2">
{maxValue}
{maxUnit ? maxUnit : ''}
</p>
{/if}
</div>
{/if}
{#if showBar && progressBarData.length > 0}
+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}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Copy } from '.';
import { sdk } from '$lib/stores/sdk';
import { Flag } from '@appwrite.io/console';
import { truncateText } from '$lib/components';
import { truncateText } from '$lib/components/id.svelte';
import { isValueOfStringEnum } from '$lib/helpers/types';
import { getProjectEndpoint } from '$lib/helpers/project';
import { projectRegion } from '$routes/(console)/project-[region]-[project]/store';
+21 -3
View File
@@ -6,17 +6,35 @@
| 'completed'
| 'processing'
| 'ready'
| 'building';
| 'building'
| 'none';
export let statusIconStyle: string | undefined = undefined;
</script>
<div
class="status"
class="status u-cross-center"
class:is-pending={status === 'pending'}
class:is-failed={status === 'failed'}
class:is-complete={status === 'completed' || status === 'ready'}
class:is-processing={status === 'processing' || status === 'building'}>
{#if status}
<span class="status-icon" />
<span class="status-icon" style={statusIconStyle} />
{/if}
<span class="text" data-private><slot /></span>
</div>
<style>
.status-icon {
width: 8px;
height: 8px;
}
.status {
gap: 6px;
}
.text {
line-height: 140%;
}
</style>
+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 const REGION_FRA = 'fra';
export const REGION_SYD = 'syd';
@@ -478,7 +479,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 = [
+4 -2
View File
@@ -13,8 +13,10 @@
export function getFlag(country: string, width: number, height: number, quality: number) {
if (!isValueOfStringEnum(Flag, country)) return '';
return sdk.forConsole.avatars.getFlag(country, width * 2, height * 2, quality);
return sdk.forConsole.avatars
.getFlag(country, width * 2, height * 2, quality)
?.toString()
?.replace('&project=console', '&mode=admin');
}
</script>
+8
View File
@@ -11,6 +11,8 @@
export let id: string;
export let name: string = id;
export let value = '';
export let pattern: string = null;
export let patternError: string = '';
export let placeholder = '';
export let required = false;
export let hideRequired = false;
@@ -45,6 +47,11 @@
return;
}
if (patternError && element.validity.patternMismatch) {
error = patternError;
return;
}
error = element.validationMessage;
};
@@ -118,6 +125,7 @@
{disabled}
{readonly}
{required}
{pattern}
{maxlength}
autocomplete={autocomplete ? 'on' : 'off'}
type="text"
+13 -2
View File
@@ -4,10 +4,21 @@ import { base } from '$app/paths';
export function checkPricingRefAndRedirect(searchParams: URLSearchParams, shouldRegister = false) {
if (searchParams.has('type')) {
const paramType = searchParams.get('type');
const hasPlan = searchParams.has('plan');
if (paramType === 'create') {
shouldRegister
? goto(
`${base}/register?type=create${hasPlan ? `&plan=${searchParams.get('plan')}` : ''}`
)
: goto(
`${base}/create-organization?type=create${hasPlan ? `&plan=${searchParams.get('plan')}` : ''}`
);
}
//Legacy
if (paramType === 'createPro') {
shouldRegister
? goto(`${base}/register?type=createPro`)
: goto(`${base}/create-organization?type=createPro`);
? goto(`${base}/register?type=create&plan=tier-1`)
: goto(`${base}/create-organization?type=create&plan=tier-1`);
}
}
}
+5
View File
@@ -48,3 +48,8 @@ const formatter = Intl.NumberFormat('en', {
export function formatNum(number: number): string {
return formatter.format(number);
}
/**
* Returns a regex to check hostname validity. Supports wildcards too!
*/
export const hostnameRegex = String.raw`(\*)|(\*\.)?(?!-)[A-Za-z0-9\-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,18}|localhost`;
+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 {};
}
+10 -8
View File
@@ -69,7 +69,7 @@
? 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) ||
@@ -86,12 +86,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>
@@ -129,7 +131,7 @@
<Pill button on:click={() => (showDropdown = !showDropdown)}>
<span class="icon-info" />{total}/{limit} created
</Pill>
{:else}
{:else if $organization?.billingPlan !== BillingPlan.SCALE}
<Pill button on:click={() => (showDropdown = !showDropdown)}>
<span class="icon-info" />Limits applied
</Pill>
+1
View File
@@ -13,6 +13,7 @@ export { default as WizardStep } from './wizardStep.svelte';
export { default as Breadcrumbs } from './breadcrumbs.svelte';
export { default as Unauthenticated } from './unauthenticated.svelte';
export { default as Usage, type UsagePeriods } from './usage.svelte';
export { default as UsageMultiple } from './usageMultiple.svelte';
export { default as Activity } from './activity.svelte';
export { default as Progress } from './progress.svelte';
export { default as GridHeader } from './gridHeader.svelte';
+4 -10
View File
@@ -8,7 +8,7 @@
import type { Coupon } from '$lib/sdk/billing';
import { app } from '$lib/stores/app';
import type { Campaign } from '$lib/stores/campaigns';
import { getApiEndpoint } from '$lib/stores/sdk';
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
export const imgLight = LoginLight;
export const imgDark = LoginDark;
@@ -38,12 +38,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">
@@ -86,7 +80,7 @@
<section
class="u-flex u-flex-vertical u-main-center u-cross-center u-height-100-percent u-width-full-line">
<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" />
@@ -151,7 +145,7 @@
<div class="u-margin-block-start-16 u-flex u-gap-16">
{#if currentReview?.image}
<Avatar
src={getImage(currentReview?.image)}
src={getCampaignImageUrl(currentReview?.image)}
name={currentReview.name}
size={40} />
{:else}
@@ -170,7 +164,7 @@
<p class="u-bold" style:text-transform="uppercase">provided to you by</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}
+37 -21
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(
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;
@@ -58,12 +68,12 @@
</script>
<script lang="ts">
import { Container } from '$lib/layout';
import { BarChart } from '$lib/charts';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Card, SecondaryTabs, SecondaryTabsItem, Heading } from '$lib/components';
import { ProjectUsageRange, type Models } from '@appwrite.io/console';
import { page } from '$app/stores';
import { BarChart } from '$lib/charts';
import { Card, Heading, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Container } from '$lib/layout';
import { ProjectUsageRange, type Models } from '@appwrite.io/console';
type MetricMetadata = {
title: string;
@@ -75,24 +85,28 @@
export let count: Models.Metric[];
export let countMetadata: MetricMetadata;
export let path: string = null;
export let hideSelectPeriod: boolean = false;
export let isCumulative: boolean = false;
</script>
<Container>
<div class="u-flex u-main-space-between common-section">
<Heading tag="h2" size="5">{title}</Heading>
<SecondaryTabs>
<SecondaryTabsItem href={`${path}/24h`} disabled={$page.params.period === '24h'}>
24h
</SecondaryTabsItem>
<SecondaryTabsItem
href={`${path}/30d`}
disabled={!$page.params.period || $page.params.period === '30d'}>
30d
</SecondaryTabsItem>
<SecondaryTabsItem href={`${path}/90d`} disabled={$page.params.period === '90d'}>
90d
</SecondaryTabsItem>
</SecondaryTabs>
{#if !hideSelectPeriod}
<SecondaryTabs>
<SecondaryTabsItem href={`${path}/24h`} disabled={$page.params.period === '24h'}>
24h
</SecondaryTabsItem>
<SecondaryTabsItem
href={`${path}/30d`}
disabled={!$page.params.period || $page.params.period === '30d'}>
30d
</SecondaryTabsItem>
<SecondaryTabsItem href={`${path}/90d`} disabled={$page.params.period === '90d'}>
90d
</SecondaryTabsItem>
</SecondaryTabs>
{/if}
</div>
<Card>
{#if count}
@@ -105,7 +119,9 @@
series={[
{
name: countMetadata.legend,
data: accumulateFromEndingTotal(count, total)
data: isCumulative
? count.map((m) => [m.date, m.value])
: accumulateFromEndingTotal(count, total)
}
]} />
</div>
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { BarChart, Legend, type LegendData } from '$lib/charts';
import { accumulateFromEndingTotal } from '$lib/layout/usage.svelte';
import { Card, Heading, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { page } from '$app/stores';
import { type Models } from '@appwrite.io/console';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
export let title: string;
export let total: number[];
export let path: string = null;
export let count: Models.Metric[][];
export let legendData: LegendData[];
export let showHeader: boolean = true;
export let overlapContainerCover = false;
</script>
<Container overlapCover={overlapContainerCover}>
<div class="u-flex u-main-space-between common-section">
{#if showHeader}
<Heading tag="h2" size="5">{title}</Heading>
{/if}
{#if path}
<SecondaryTabs>
<SecondaryTabsItem href={`${path}/24h`} disabled={$page.params.period === '24h'}>
24h
</SecondaryTabsItem>
<SecondaryTabsItem
href={`${path}/30d`}
disabled={!$page.params.period || $page.params.period === '30d'}>
30d
</SecondaryTabsItem>
<SecondaryTabsItem href={`${path}/90d`} disabled={$page.params.period === '90d'}>
90d
</SecondaryTabsItem>
</SecondaryTabs>
{/if}
</div>
<Card>
{#if count}
{@const totalCount = total.reduce((a, b) => a + b, 0)}
<Heading tag="h6" size="6">{formatNumberWithCommas(totalCount)}</Heading>
<p>Total {title.toLocaleLowerCase()}</p>
<div class="u-margin-block-start-16" />
<div class="multiple-chart-container u-flex-vertical u-gap-16">
<BarChart
formatted={$page.params.period === '24h' ? 'hours' : 'days'}
series={count.map((c, index) => ({
name: legendData[index].name,
data: accumulateFromEndingTotal(c, total[index])
}))} />
{#if legendData}
<Legend {legendData} />
{/if}
</div>
{/if}
</Card>
</Container>
<style lang="scss">
.multiple-chart-container {
height: 12rem;
}
:global(.multiple-chart-container .echart) {
margin-top: -1em;
margin-bottom: -1em;
}
</style>
+2
View File
@@ -67,6 +67,8 @@
const step = e.detail;
if (step < $wizard.step) {
$wizard.step = step;
// clear the interceptor
wizard.setInterceptor(null);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
</script>
<Modal
title="Exit Process"
title="Exit process"
bind:show
onSubmit={handleSubmit}
icon="exclamation"
+10 -4
View File
@@ -1,9 +1,15 @@
<script>
export let hideSidebar = false;
</script>
<div class="wizard-secondary-content">
<div class="wizard-secondary-content-1">
<slot />
</div>
<div class="wizard-secondary-content-sep"></div>
<div class="wizard-secondary-content-2">
<slot name="aside" />
</div>
{#if !hideSidebar}
<div class="wizard-secondary-content-sep"></div>
<div class="wizard-secondary-content-2">
<slot name="aside" />
</div>
{/if}
</div>
+178 -25
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;
@@ -146,10 +172,12 @@ export type Aggregation = {
* Total amount of the invoice.
*/
amount: number;
additionalMembers: number;
/**
* Price for additional members
*/
additionalMembers: number;
additionalMemberAmount: number;
/**
* Total storage usage.
*/
@@ -174,14 +202,24 @@ export type Aggregation = {
* Usage logs for the billing period.
*/
resources: OrganizationUsage;
/**
* Aggregation billing plan
*/
plan: string;
};
export type OrganizationUsage = {
bandwidth: Array<Models.Metric>;
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;
@@ -194,9 +232,12 @@ export type OrganizationUsage = {
storage: number;
executions: number;
bandwidth: number;
databasesReads: number;
databasesWrites: number;
users: number;
authPhoneTotal: number;
authPhoneEstimate: number;
imageTransformations: number;
}>;
authPhoneTotal: number;
authPhoneEstimate: number;
@@ -310,7 +351,7 @@ export type Plan = {
supportsCredits: boolean;
};
export type PlansInfo = {
export type PlanList = {
plans: Plan[];
total: number;
};
@@ -345,20 +386,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(
@@ -371,6 +437,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 = {
@@ -387,20 +475,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> {
@@ -415,14 +513,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(
@@ -435,6 +541,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,
@@ -758,6 +895,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 = {
@@ -1130,7 +1283,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;
};
+72 -52
View File
@@ -1,40 +1,39 @@
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 { page } from '$app/stores';
import { trackEvent } from '$lib/actions/analytics';
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';
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 = [
{
@@ -81,6 +80,8 @@ export function tierToPlan(tier: Tier) {
return tierGitHubEducation;
case BillingPlan.CUSTOM:
return tierCustom;
case BillingPlan.ENTERPRISE:
return tierEnterprise;
default:
return tierFree;
}
@@ -129,7 +130,8 @@ export type PlanServices =
| 'users'
| 'usersAddon'
| 'webhooks'
| 'authPhone';
| 'authPhone'
| 'imageTransformations';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
if (!isCloud) return 0;
@@ -150,11 +152,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);
@@ -175,7 +178,7 @@ export type TierData = {
export const tierFree: TierData = {
name: 'Free',
description: 'For personal hobby projects of small scale and students.'
description: 'A great fit for passion projects and small applications.'
};
export const tierGitHubEducation: TierData = {
@@ -185,11 +188,13 @@ export const tierGitHubEducation: TierData = {
export const tierPro: TierData = {
name: 'Pro',
description: 'For pro developers and production projects that need the ability to scale.'
description:
'For production applications that need powerful functionality and resources to scale.'
};
export const tierScale: TierData = {
name: 'Scale',
description: 'For scaling teams and agencies that need dedicated support.'
description:
'For teams that handle more complex and large projects and need more control and support.'
};
export const tierCustom: TierData = {
@@ -197,6 +202,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) {
@@ -454,30 +464,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.addons.seats.limit)
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
executions: calculateResourceSurplus(addon.usageExecutions, plan.executions, 'GB'),
members: addon.additionalMembers
};
}
@@ -486,3 +502,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;
}
+13 -8
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);
@@ -21,15 +23,18 @@ export async function checkForDatabaseBackupPolicies(
if (!shouldShowNotification(backupsBannerId)) return;
let total = 0;
const backupsEnabled = get(currentPlan)?.backupsEnabled ?? true;
try {
const policies = await sdk
.forProject(region, projectId)
.backups.listPolicies([Query.limit(1), Query.equal('resourceId', database.$id)]);
if (isCloud && backupsEnabled) {
try {
const policies = await sdk
.forProject(region, projectId)
.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);
+11
View File
@@ -4,6 +4,15 @@ import type { Models } from '@appwrite.io/console';
import type { Tier } from './billing';
import type { Plan, RegionList } 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,8 @@ export type Organization = Models.Team<Record<string, unknown>> & {
amount: number;
billingTaxId?: string;
billingPlanDowngrade?: Tier;
billingAggregationId: string;
billingInvoiceId: string;
};
export type OrganizationList = {
@@ -39,7 +39,8 @@
$repository.id,
$choices.branch,
$choices.silentMode || undefined,
$choices.rootDir
$choices.rootDir,
$func.specification || undefined
);
trackEvent(Submit.FunctionConnectRepo, {
customId: !!$func.$id
+3 -3
View File
@@ -160,9 +160,9 @@
</div>
<div class="body-text-2">
{runtimeDetail.name}
{#if runtimeDetail.name.toLowerCase() === 'deno'}
<span class="inline-tag">New</span>
{/if}
<!--{#if runtimeDetail.name.toLowerCase() === 'deno'}-->
<!-- <span class="inline-tag">New</span>-->
<!--{/if}-->
</div>
</button>
</li>
@@ -18,10 +18,10 @@
<svelte:fragment slot="aside">
<BoxAvatar>
<svelte:fragment slot="image">
<AvatarInitials size={48} name={$user.name} />
<AvatarInitials size={48} 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>
@@ -12,7 +12,7 @@
let name: string = null;
onMount(async () => {
name ??= $user.name;
name ??= $user.name ?? '';
});
async function updateName() {
+90 -54
View File
@@ -3,11 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
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, FormList, InputSelect, InputTags, InputText } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
@@ -16,14 +12,21 @@
WizardSecondaryContent,
WizardSecondaryFooter
} from '$lib/layout';
import { type PaymentList } from '$lib/sdk/billing';
import { type PaymentList, type Plan } from '$lib/sdk/billing';
import { app } from '$lib/stores/app';
import { isOrganization } from '$lib/stores/billing.js';
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 { getCampaignImageUrl } from '$routes/(public)/card/helpers';
export let data;
@@ -69,6 +72,12 @@
let couponData = data?.couponData;
let campaign = data?.campaign;
let billingPlan = BillingPlan.PRO;
let currentPlan: Plan;
function isUpgrade() {
const newPlan = $page.data.plansInfo.get(billingPlan);
return currentPlan && newPlan && currentPlan.order < newPlan.order;
}
onMount(async () => {
await loadPaymentMethods();
@@ -82,6 +91,20 @@
if (campaign?.plan) {
billingPlan = campaign.plan;
}
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() {
@@ -96,24 +119,33 @@
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
@@ -121,51 +153,47 @@
org = selectedOrg;
}
// Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
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 budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
}
// 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);
}
}
@@ -194,6 +222,14 @@
selectedOrg?.billingPlan === BillingPlan.SCALE
? BillingPlan.SCALE
: (campaign?.plan ?? BillingPlan.PRO);
$: {
if (selectedOrgId) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
})();
}
}
</script>
<svelte:head>
@@ -214,7 +250,7 @@
placeholder="Select organization"
id="organization" />
{/if}
{#if selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)}
{#if selectedOrgId && (selectedOrg?.billingPlan === BillingPlan.FREE || !selectedOrg?.paymentMethodId)}
{#if selectedOrgId === newOrgId}
<InputText
label="Organization name"
@@ -260,7 +296,7 @@
<div class="card-bg"></div>
<div class="u-flex u-flex-vertical u-gap-24 u-cross-center u-position-relative">
<img
src={campaign?.image[$app.themeInUse]}
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
class="u-block u-image-object-fit-cover card-img"
alt="promo" />
<p class="text">
@@ -290,12 +326,12 @@
</section>
{:else if selectedOrgId}
<div class:u-margin-block-start-24={campaign?.template === 'card'}>
<EstimatedTotalBox
fixedCoupon={!!data?.couponData?.code}
<EstimatedTotal
{billingBudget}
organizationId={selectedOrgId === newOrgId ? undefined : selectedOrgId}
{billingPlan}
{collaborators}
bind:couponData
bind:billingBudget>
{couponData}>
{#if campaign?.template === 'review' && (campaign?.cta || campaign?.claimed || campaign?.unclaimed)}
<div class="u-margin-block-end-24">
<p class="body-text-1 u-bold">{campaign?.cta}</p>
@@ -308,7 +344,7 @@
</p>
</div>
{/if}
</EstimatedTotalBox>
</EstimatedTotal>
</div>
{/if}
</svelte:fragment>
@@ -330,7 +366,7 @@
{#if selectedOrgId === newOrgId}
Create Organization
{:else}
Apply Credits
Apply credits
{/if}
</Button>
</WizardSecondaryFooter>
+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);
}
@@ -3,12 +3,8 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
EstimatedTotalBox,
PlanComparisonBox,
PlanSelection,
SelectPaymentMethod
} from '$lib/components/billing';
import { PlanComparisonBox, SelectPaymentMethod, SelectPlan } from '$lib/components/billing';
import EstimatedTotal from '$lib/components/billing/estimatedTotal.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
@@ -19,10 +15,15 @@
WizardSecondaryFooter
} from '$lib/layout';
import type { Coupon, PaymentList } from '$lib/sdk/billing';
import { tierToPlan } from '$lib/stores/billing';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { organizationList, type Organization } from '$lib/stores/organization';
import {
organizationList,
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 { onMount } from 'svelte';
import { writable } from 'svelte/store';
@@ -60,7 +61,7 @@
if ($page.url.searchParams.has('coupon')) {
const coupon = $page.url.searchParams.get('coupon');
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
@@ -86,6 +87,14 @@
) {
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() {
@@ -93,9 +102,29 @@
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 (billingPlan === BillingPlan.FREE) {
org = await sdk.forConsole.billing.createOrganization(
@@ -111,53 +140,49 @@
name,
billingPlan,
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 (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.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(billingPlan)?.name,
budget_cap_enabled: !!billingBudget,
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',
@@ -193,7 +218,7 @@
For more details on our plans, visit our
<Button href="https://appwrite.io/pricing" external link>pricing page</Button>.
</p>
<PlanSelection bind:billingPlan {anyOrgFree} isNewOrg />
<SelectPlan bind:billingPlan {anyOrgFree} isNewOrg />
{#if billingPlan !== BillingPlan.FREE}
<FormList class="u-margin-block-start-24">
<InputTags
@@ -220,11 +245,7 @@
</Form>
<svelte:fragment slot="aside">
{#if billingPlan !== BillingPlan.FREE}
<EstimatedTotalBox
{billingPlan}
{collaborators}
bind:couponData
bind:billingBudget />
<EstimatedTotal bind:billingBudget {billingPlan} {collaborators} bind:couponData />
{:else}
<PlanComparisonBox />
{/if}
+3 -20
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { Modal, CustomId } from '$lib/components';
import { Pill } from '$lib/elements';
import { Modal } from '$lib/components';
import { InputText, Button, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
@@ -16,15 +15,13 @@
export let show = false;
let name: string;
let id: string;
let showCustomId = false;
let error: string;
const dispatch = createEventDispatcher();
async function create() {
try {
const org = await sdk.forConsole.teams.create(id ?? ID.unique(), name);
const org = await sdk.forConsole.teams.create(ID.unique(), name);
await invalidate(Dependencies.ACCOUNT);
dispatch('created');
await goto(`${base}/organization-${org.$id}`);
@@ -32,11 +29,8 @@
type: 'success',
message: `${name} has been created`
});
trackEvent(Submit.OrganizationCreate, {
customId: !!id
});
trackEvent(Submit.OrganizationCreate);
name = null;
id = null;
show = false;
} catch (e) {
error = e.message;
@@ -66,17 +60,6 @@
bind:value={name}
autofocus={true}
required />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" /><span class="text">
Organization ID
</span>
</Pill>
</div>
{:else}
<CustomId autofocus bind:show={showCustomId} name="Organization" bind:id />
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Cancel</Button>
+19 -33
View File
@@ -3,9 +3,7 @@
import { page } from '$app/stores';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Card, Heading } from '$lib/components';
import CustomId from '$lib/components/customId.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Pill } from '$lib/elements';
import { Button, Form, InputSelect, InputText } from '$lib/elements/forms';
import FormList from '$lib/elements/forms/formList.svelte';
import { Container } from '$lib/layout';
@@ -14,14 +12,12 @@
import { isCloud } from '$lib/system';
import { ID } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { tierToPlan, type Tier, plansInfo } from '$lib/stores/billing';
import { tierToPlan, type Tier, plansInfo, isOrganization } from '$lib/stores/billing';
import { formatCurrency } from '$lib/helpers/numbers';
import { base } from '$app/paths';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
let name: string;
let id: string;
let showCustomId = false;
let plan: Tier;
const options = isCloud
@@ -33,11 +29,11 @@
{
value: BillingPlan.PRO,
label: `${tierToPlan(BillingPlan.PRO).name} - ${formatCurrency($plansInfo.get(BillingPlan.PRO).price)} / month + add-ons`
},
{
value: BillingPlan.SCALE,
label: `${tierToPlan(BillingPlan.SCALE).name} - ${formatCurrency($plansInfo.get(BillingPlan.SCALE).price)}/month + usage`
}
// {
// value: BillingPlan.SCALE,
// label: `${tierToPlan(BillingPlan.SCALE).name} - ${formatCurrency($plansInfo.get(BillingPlan.SCALE).price)}/month + usage`
// }
]
: [];
@@ -53,22 +49,28 @@
if (plan === BillingPlan.FREE) {
try {
const org = await sdk.forConsole.billing.createOrganization(
id ?? ID.unique(),
ID.unique(),
orgName,
plan,
null,
null
);
trackEvent(Submit.OrganizationCreate, {
customId: !!id,
plan: tierToPlan(plan)?.name
});
await invalidate(Dependencies.ACCOUNT);
await goto(`${base}/organization-${org.$id}`);
addNotification({
message: `${orgName} organization successfully created`,
type: 'success'
});
if (isOrganization(org)) {
await goto(`${base}/organization-${org.$id}`);
addNotification({
message: `${orgName} organization successfully created`,
type: 'success'
});
} else {
addNotification({
message: `${org.message}`,
type: 'error'
});
}
} catch (error) {
addNotification({
message: error.message,
@@ -81,7 +83,7 @@
}
} else {
try {
const org = await sdk.forConsole.teams.create(id ?? ID.unique(), orgName);
const org = await sdk.forConsole.teams.create(ID.unique(), orgName);
await invalidate(Dependencies.ACCOUNT);
await goto(`${base}/organization-${org.$id}`);
@@ -112,22 +114,6 @@
placeholder="Organization name"
hideRequired
bind:value={name} />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" /><span class="text">
Organization ID
</span>
</Pill>
</div>
{:else}
<CustomId
autofocus
bind:show={showCustomId}
name="Organization"
isProject
bind:id />
{/if}
{#if isCloud}
<div class="u-margin-block-start-8">
<h3><b>Plan</b></h3>
@@ -28,7 +28,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)) {
@@ -29,14 +29,15 @@
import { openImportWizard } from '../project-[region]-[project]/settings/migrations/(import)';
import { readOnly } from '$lib/stores/billing';
import { onMount } from 'svelte';
import { organization, regions } from '$lib/stores/organization';
import { organization } from '$lib/stores/organization';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
import { regions as regionsStore } from '$routes/(console)/organization-[organization]/store';
export let data;
let addOrganization = false;
let showCreate = false;
let addOrganization = false;
const getPlatformInfo = (platform: string) => {
let name: string, icon: string;
@@ -84,6 +85,7 @@
if (isCloud) wizard.start(Create);
else showCreate = true;
}
$: $registerCommands([
{
label: 'Create project',
@@ -118,15 +120,16 @@
trackError(e, Submit.ProjectCreate);
}
};
onMount(async () => {
if (isCloud) {
if (isCloud && $organization.$id) {
const regions = await sdk.forConsole.billing.listRegions($organization.$id);
regionsStore.set(regions);
checkPricingRefAndRedirect($page.url.searchParams);
}
});
function findRegion(project: Models.Project) {
return $regions.regions.find((region) => region.$id === project.region);
return $regionsStore?.regions?.find((region) => region.$id === project.region);
}
</script>
@@ -194,7 +197,7 @@
</Pill>
{/if}
<svelte:fragment slot="icons">
{#if isCloud && regions}
{#if isCloud && $regionsStore?.regions}
{@const region = findRegion(project)}
<span class="u-color-text-gray u-medium u-line-height-2">
{region?.name}
@@ -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';
@@ -110,10 +109,9 @@
{/if}
{#if $organization?.billingPlanDowngrade}
<Alert type="info" class="common-section">
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>
{/if}
<div class="common-section">
@@ -121,15 +119,14 @@
</div>
<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>
@@ -4,7 +4,6 @@ import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { Query } from '@appwrite.io/console';
export const load: PageLoad = async ({ parent, depends }) => {
const { organization, scopes } = await parent();
@@ -25,25 +24,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
};
};
@@ -20,6 +20,8 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
export let alertsEnabled = false;
let search: string;
let selectedAlert: number;
let alerts: number[] = [];
@@ -74,7 +76,8 @@
}
}
$: isButtonDisabled = symmetricDifference(alerts, $organization.budgetAlerts).length === 0;
$: isButtonDisabled =
symmetricDifference(alerts, $organization.budgetAlerts).length === 0 || !alertsEnabled;
</script>
<Form onSubmit={updateBudget}>
@@ -107,6 +110,7 @@
<div class="u-flex u-gap-16">
<InputSelectSearch
disabled={!alertsEnabled}
label="Percentage (%) of budget cap"
placeholder="Select a percentage"
id="alerts"
@@ -118,7 +122,9 @@
<div style="align-self: flex-end">
<Button
secondary
disabled={alerts.length > 3 || (!search && !selectedAlert)}
disabled={alerts.length > 3 ||
(!search && !selectedAlert) ||
!alertsEnabled}
on:click={addAlert}>
Add alert
</Button>
@@ -9,13 +9,14 @@
import { organization, currentPlan } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
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() {
@@ -44,7 +45,7 @@
}
$: if (!capActive) {
budget = 0;
budget = null;
}
</script>
@@ -113,3 +114,5 @@
</svelte:fragment>
</CardGrid>
</Form>
<BudgetAlert alertsEnabled={capActive && budget > 0} />
@@ -0,0 +1,51 @@
<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>
<div class="max-height-dialog">
<Modal
title="Cancel plan change"
onSubmit={cancelDowngrade}
bind:show={showCancel}
bind:error
icon="exclamation"
state="warning"
headerDivider={false}>
<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>
</div>
@@ -23,13 +23,12 @@
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';
import { selectedInvoice, showRetryModal } from './store';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import { base } from '$app/paths';
let showDropdown = [];
let showFailedError = false;
@@ -41,7 +40,6 @@
};
const limit = 5;
const endpoint = getApiEndpoint();
onMount(request);
@@ -49,8 +47,6 @@
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')
]);
}
@@ -65,144 +61,150 @@
}
</script>
{#if $organization?.billingPlan === BillingPlan.FREE && invoiceList.total > 0}
<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>
<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>
<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' ||
status === 'succeeded'}
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed.
<Button
link
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}
>Try again
</Button>
.
</li>
</svelte:fragment>
</DropList>
{:else}
<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={110}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() =>
(showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
<li>
The scheduled payment has failed.
<Button
link
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
showFailedError = false;
}}
>Try again
</Button>
.
</li>
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList?.total ?? 0}</p>
<PaginationInline {limit} bind:offset sum={invoiceList?.total ?? 0} hidePages />
</div>
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll
see it here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
{/if}
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
{status === 'requires_authentication' ? 'failed' : status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${base}/organization-${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() => (showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${base}/organization-${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList.total}</p>
<PaginationInline {limit} bind:offset sum={invoiceList.total} hidePages />
</div>
<!--{:else if isLoadingInvoices}-->
<!-- <div class="loader-holder">-->
<!-- <div class="loader" />-->
<!-- </div>-->
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll see it
here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
<!--<style>-->
<!-- .loader-holder {-->
<!-- height: 100%;-->
<!-- align-content: center;-->
<!-- justify-items: center;-->
<!-- }-->
<!--</style>-->
@@ -3,30 +3,29 @@
import { Card, CardGrid, Collapsible, CollapsibleItem, Heading } 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 { trackEvent } from '$lib/actions/analytics';
import { tooltip } from '$lib/actions/tooltip';
import { type Models } from '@appwrite.io/console';
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 =
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
$plansInfo.get($organization.billingPlan)?.trialDays;
const extraUsage = currentInvoice ? currentInvoice.amount - currentPlan?.price : 0;
const extraAddons = currentInvoice ? currentInvoice.usage?.length : 0;
</script>
{#if $organization}
@@ -39,16 +38,13 @@
</p>
<svelte:fragment slot="aside">
<p class="text u-bold">
Billing period: {toLocaleDate($organization?.billingCurrentInvoiceDate)} - {toLocaleDate(
$organization?.billingNextInvoiceDate
)}
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Card isTile style="--p-card-padding: 1.5rem;" class="card-only-on-desktop">
<Collapsible>
<div class="u-flex-vertical u-gap-8">
<div class="u-flex">
<span class="body-text-2">
{tierToPlan($organization?.billingPlan)?.name} plan</span>
<span class="body-text-2">{currentPlan.name} plan</span>
<div class="body-text-2 u-margin-inline-start-auto">
{isTrial ||
$organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
@@ -59,12 +55,14 @@
</div>
</div>
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION && extraUsage > 0}
{#if currentPlan.budgeting && extraUsage > 0}
<CollapsibleItem gap={8}>
<svelte:fragment slot="beforetitle">
<span class="body-text-2"><b>Add-ons</b></span><span
class="inline-tag"
>{extraMembers ? extraAddons + 1 : extraAddons}</span>
>{currentAggregation.additionalMembers > 0
? currentInvoice.usage.length + 1
: currentInvoice.usage.length}</span>
<div class="icon">
<span class="icon-cheveron-down" aria-hidden="true"></span>
</div>
@@ -78,7 +76,7 @@
</svelte:fragment>
<ul>
{#if extraMembers}
{#if currentAggregation.additionalMembers}
<li class="u-flex-vertical u-gap-4 u-padding-block-8">
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch">
@@ -86,15 +84,14 @@
</h5>
<div>
{formatCurrency(
extraMembers *
(currentPlan?.addons?.seats?.price ?? 0)
currentAggregation.additionalMemberAmount
)}
</div>
</div>
<div class="u-flex u-gap-4">
<h5
class="body-text-2 u-stretch u-color-text-offline">
{extraMembers}
{currentAggregation.additionalMembers}
</h5>
</div>
</li>
@@ -102,10 +99,12 @@
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
<li
class="u-flex-vertical u-gap-4 {extraMembers
class="u-flex-vertical u-gap-4 {currentAggregation.additionalMembers >
0
? 'u-padding-block-8'
: 'u-padding-block-start-8'}"
class:u-sep-block-start={i > 0 || extraMembers}>
class:u-sep-block-start={i > 0 ||
currentAggregation.additionalMembers > 0}>
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(
excess.value
@@ -124,8 +123,7 @@
) + 'bytes'}>
{excessValue.value ?? 0}{excessValue.unit}
</h5>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
{:else if excess.name !== 'member'}
<div class="u-flex u-main-space-between">
<h5
class="body-text-2 u-stretch u-capitalize">
@@ -148,7 +146,7 @@
</CollapsibleItem>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && availableCredit > 0}
{#if currentPlan.supportsCredits && availableCredit > 0}
<CollapsibleItem noContent gap={4}>
<span class="body-text-2 u-flex u-cross-center u-gap-2"
><svg
@@ -230,17 +228,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>
@@ -249,6 +251,7 @@
</svelte:fragment>
</CardGrid>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
@@ -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;
@@ -4,13 +4,10 @@
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert } from '$lib/components';
import {
EstimatedTotalBox,
PlanComparisonBox,
SelectPaymentMethod
} from '$lib/components/billing';
import { PlanComparisonBox, SelectPaymentMethod } from '$lib/components/billing';
import EstimatedTotal from '$lib/components/billing/estimatedTotal.svelte';
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import PlanSelection from '$lib/components/billing/planSelection.svelte';
import SelectPlan from '$lib/components/billing/selectPlan.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
@@ -23,14 +20,15 @@
InputTextarea,
Label
} from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers.js';
import { toLocaleDate } from '$lib/helpers/date.js';
import { formatCurrency } from '$lib/helpers/numbers';
import {
WizardSecondaryContainer,
WizardSecondaryContent,
WizardSecondaryFooter
} from '$lib/layout';
import { type Coupon, type PaymentList } from '$lib/sdk/billing';
import { plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { isOrganization, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import {
currentPlan,
@@ -39,6 +37,7 @@
type 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 { onMount } from 'svelte';
@@ -59,7 +58,7 @@
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let billingPlan: Tier = $organization.billingPlan;
let billingPlan: Tier = $currentPlan?.$id as Tier;
let paymentMethodId: string;
let collaborators: string[] =
data?.members?.memberships
@@ -84,7 +83,7 @@
if ($page.url.searchParams.has('code')) {
const coupon = $page.url.searchParams.get('code');
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
@@ -100,7 +99,16 @@
billingPlan = plan as BillingPlan;
}
}
if ($organization?.billingPlan === BillingPlan.SCALE) {
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) {
billingPlan = BillingPlan.SCALE;
} else {
billingPlan = BillingPlan.PRO;
@@ -141,7 +149,7 @@
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: tierToPlan($organization.billingPlan).name,
from: tierToPlan($currentPlan?.$id as Tier).name,
to: tierToPlan(billingPlan).name,
email: $user.email,
reason: feedbackDowngradeOptions.find(
@@ -153,14 +161,14 @@
})
});
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
isHtml: true,
message: `
<b>${$organization.name}</b> will change to ${
tierToPlan(billingPlan).name
} plan at the end of the current billing cycle.`
<b>${$organization.name}</b> plan has been successfully updated.`
});
trackEvent(Submit.OrganizationDowngrade, {
@@ -175,61 +183,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(billingPlan)?.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(
$organization.$id,
billingPlan,
paymentMethodId,
null
null,
couponData?.code,
newCollaborators,
billingBudget,
taxId ? taxId : null
);
//Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.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', billingPlan);
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(billingPlan)?.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(billingPlan)?.name
});
} catch (e) {
addNotification({
type: 'error',
@@ -239,12 +274,12 @@
}
}
$: isUpgrade = billingPlan > $organization.billingPlan;
$: isDowngrade = billingPlan < $organization.billingPlan;
$: isUpgrade = $plansInfo.get(billingPlan).order > $currentPlan.order;
$: isDowngrade = $plansInfo.get(billingPlan).order < $currentPlan.order;
$: if (billingPlan !== BillingPlan.FREE) {
loadPaymentMethods();
}
$: isButtonDisabled = $organization.billingPlan === billingPlan;
$: isButtonDisabled = ($currentPlan?.$id as Tier) === billingPlan;
</script>
<svelte:head>
@@ -265,11 +300,7 @@
>Your contract is not eligible for manual changes. Please reach out to schedule
a call or setup a dialog.</Alert>
{/if}
<PlanSelection
bind:billingPlan
bind:selfService
anyOrgFree={!!anyOrgFree}
class="u-margin-block-16" />
<SelectPlan bind:billingPlan anyOrgFree={!!anyOrgFree} class="u-margin-block-16" />
{#if isDowngrade}
{#if billingPlan === BillingPlan.FREE}
@@ -277,24 +308,27 @@
tier={BillingPlan.FREE}
class="u-margin-block-start-24"
members={data?.members?.total ?? 0} />
{:else if billingPlan === BillingPlan.PRO && $organization.billingPlan === BillingPlan.SCALE}
{:else}
{@const extraMembers = collaborators?.length ?? 0}
<Alert type="error" class="u-margin-block-start-24">
<Alert type="warning" class="u-margin-block-start-24">
<svelte:fragment slot="title">
Your monthly payments will be adjusted for the Pro plan
Your organization will switch to {tierToPlan(billingPlan).name} plan on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
</svelte:fragment>
After switching plans,
<b
>you will be charged {formatCurrency(
extraMembers *
($plansInfo?.get(billingPlan)?.addons?.seats?.price ?? 0)
)} monthly for {extraMembers} team members.</b> This will be reflected in
your next invoice.
You will retain access to {tierToPlan($organization.billingPlan).name} plan features
until your billing period ends. {#if extraMembers > 0}After that,
<b
>you will be charged {formatCurrency(
extraMembers *
($plansInfo?.get(billingPlan)?.addons?.seats?.price ?? 0)
)} per month for {extraMembers} team members.</b
>{/if}
</Alert>
{/if}
{/if}
<!-- Show email input if upgrading from free plan -->
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan === BillingPlan.FREE}
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan && $organization.billingPlan !== BillingPlan.CUSTOM && isUpgrade}
<FormList class="u-margin-block-start-16">
<InputTags
bind:tags={collaborators}
@@ -316,7 +350,7 @@
</Button>
{/if}
{/if}
{#if isDowngrade}
{#if isDowngrade && billingPlan === BillingPlan.FREE}
<FormList class="u-margin-block-start-24">
<InputSelect
id="reason"
@@ -335,13 +369,13 @@
{/if}
</Form>
<svelte:fragment slot="aside">
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan && $organization.billingPlan !== BillingPlan.CUSTOM}
<EstimatedTotalBox
{billingPlan}
{collaborators}
bind:couponData
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan && $organization.billingPlan !== BillingPlan.CUSTOM && isUpgrade}
<EstimatedTotal
bind:billingBudget
{isDowngrade} />
bind:couponData
organizationId={$organization.$id}
{billingPlan}
{collaborators} />
{:else if $organization.billingPlan !== BillingPlan.CUSTOM}
<PlanComparisonBox downgrade={isDowngrade} />
{/if}
@@ -1,18 +1,18 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { logout } from '$lib/helpers/logout';
import { checkForUsageLimit } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { isCloud } from '$lib/system';
import type { Models } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { user } from '$lib/stores/user';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { checkForUsageLimit } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { logout } from '$lib/helpers/logout';
const dispatch = createEventDispatcher();
@@ -36,7 +36,7 @@
showDelete = false;
addNotification({
type: 'success',
message: `${selectedMember.userName} was deleted from ${selectedMember.teamName}`
message: `${selectedMember.userName || 'User'} was deleted from ${selectedMember.teamName}`
});
trackEvent(Submit.MemberDelete);
} catch (error) {
@@ -0,0 +1,14 @@
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
// verify invoice exists
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
const endpoint = getApiEndpoint();
return redirect(
302,
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/download`
);
};
@@ -0,0 +1,14 @@
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
// verify invoice exists
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
const endpoint = getApiEndpoint();
return redirect(
302,
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/view`
);
};
@@ -1,7 +1,15 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { AvatarInitials, DropList, DropListItem, PaginationWithLimit } from '$lib/components';
import {
AvatarInitials,
Drop,
DropList,
DropListItem,
PaginationWithLimit
} from '$lib/components';
import Upgrade from '$lib/components/roles/upgrade.svelte';
import { Pill } from '$lib/elements';
import {
TableBody,
@@ -13,18 +21,15 @@
TableScroll
} from '$lib/elements/table';
import { Container, ContainerHeader } from '$lib/layout';
import { getRoleLabel } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { newMemberModal, organization } from '$lib/stores/organization';
import { isOwner } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import Delete from '../deleteMember.svelte';
import { base } from '$app/paths';
import { isOwner } from '$lib/stores/roles';
import type { PageData } from './$types';
import Edit from './edit.svelte';
import { getRoleLabel } from '$lib/stores/billing';
import { Drop } from '$lib/components';
import Upgrade from '$lib/components/roles/upgrade.svelte';
export let data: PageData;
@@ -113,9 +118,11 @@
<TableRow>
<TableCell title="Name">
<div class="u-flex u-gap-12 u-cross-center">
<AvatarInitials size={40} name={member.userName} />
<AvatarInitials
size={40}
name={member.userName || member.userEmail} />
<span class="text u-trim">
{member.userName ? member.userName : 'n/a'}
{member.userName || 'n/a'}
</span>
{#if member.invited && !member.joined}
<Pill warning>Pending</Pill>
@@ -16,7 +16,6 @@
import Baa from './BAA.svelte';
import Soc2 from './Soc2.svelte';
export let data;
let name: string;
let showDelete = false;
@@ -106,4 +105,4 @@
{/if}
</Container>
<Delete bind:showDelete invoices={data.invoices} />
<Delete bind:showDelete />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Alert } from '$lib/components';
import type { EstimationDeleteOrganization } from '$lib/sdk/billing';
import InvoicesTable from './invoicesTable.svelte';
export let estimation: EstimationDeleteOrganization;
</script>
{#if estimation}
{#if estimation.unpaidInvoices?.length > 0}
<Alert type="warning">
This organization has unresolved invoices that must be settled before it can be deleted.
Please review and resolve these invoices to proceed.
</Alert>
<InvoicesTable invoices={estimation.unpaidInvoices} showActions={false} />
{/if}
{/if}
@@ -1,17 +1,17 @@
<script lang="ts">
import { Alert, Modal, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { Modal, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { Button, FormList, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { members, organization, organizationList } from '$lib/stores/organization';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { projects } from '../store';
import { toLocaleDate } from '$lib/helpers/date';
import { isCloud } from '$lib/system';
import type { InvoiceList } from '$lib/sdk/billing';
import type { EstimationDeleteOrganization } from '$lib/sdk/billing';
import {
TableBody,
TableCell,
@@ -20,16 +20,18 @@
TableRow,
TableScroll
} from '$lib/elements/table';
import { formatCurrency } from '$lib/helpers/numbers';
import { tierToPlan } from '$lib/stores/billing';
import { onMount } from 'svelte';
import DeleteOrganizationEstimation from './deleteOrganizationEstimation.svelte';
import { billingURL } from '$lib/stores/billing';
export let showDelete = false;
export let invoices: InvoiceList;
let error: string = null;
let selectedTab = 'projects';
let organizationName: string = null;
let estimation: EstimationDeleteOrganization;
/* enable overflow-x */
const columnWidth = 120;
const columnWidthSmall = columnWidth / 4;
@@ -55,10 +57,7 @@
showDelete = false;
addNotification({
type: 'success',
message:
$organization.billingPlan === BillingPlan.FREE
? `${$organization.name} has been deleted`
: `${$organization.name} has been flagged for deletion`
message: `${$organization.name} has been deleted`
});
} catch (e) {
error = e.message;
@@ -66,6 +65,8 @@
}
}
onMount(() => getEstimate());
const tabs = [
{
name: 'projects',
@@ -94,12 +95,23 @@
}))
};
$: upcomingInvoice = invoices?.invoices.find((i) => i.status === 'upcoming' && i.amount > 0);
$: if (!showDelete) {
// reset on close.
organizationName = '';
}
async function getEstimate() {
if (isCloud) {
try {
error = '';
estimation = await sdk.forConsole.billing.estimationDeleteOrganization(
$organization.$id
);
} catch (e) {
error = e.message;
}
}
}
</script>
<div class="max-height-dialog">
@@ -111,111 +123,109 @@
icon="exclamation"
state="warning"
headerDivider={false}>
{#if upcomingInvoice}
<Alert type="warning">
<span slot="title">
You have a pending {formatCurrency(upcomingInvoice.grossAmount)} invoice for your
{tierToPlan(upcomingInvoice.plan).name} plan
</span>
<p>
By proceeding, your invoice will be processed within the hour. Upon successful
payment, your organization will be deleted.
</p>
</Alert>
{/if}
{#if estimation && (estimation.unpaidInvoices.length > 0 || estimation.grossAmount > 0)}
<DeleteOrganizationEstimation {estimation} />
{:else}
<p data-private>
{#if $projects.total > 0}
The following projects and all data associated with <b>{$organization.name}</b>
will be permanently deleted. <b>This action is irreversible</b>.
{:else}
All data associated with <b>{$organization.name}</b> will be permanently
deleted.
<b>This action is irreversible</b>.
{/if}
</p>
<p data-private>
{#if $projects.total > 0}
The following projects and all data associated with <b>{$organization.name}</b> will
be permanently deleted. <b>This action is irreversible</b>.
{:else}
All data associated with <b>{$organization.name}</b> will be permanently deleted.
<b>This action is irreversible</b>.
<div class="box is-only-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.desktop} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header}
<TableCellHead width={columnWidth}>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell}
<TableCell width={columnWidth}>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
<div class="box is-not-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.mobile} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header, index}
<TableCellHead width={index === 1 ? columnWidthSmall : columnWidth}
>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell, index}
<TableCell
width={index === 1 ? columnWidthSmall : columnWidth}
>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
{/if}
</p>
{#if $projects.total > 0}
<div class="box is-only-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.desktop} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header}
<TableCellHead width={columnWidth}>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell}
<TableCell width={columnWidth}>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
<div class="box is-not-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.mobile} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header, index}
<TableCellHead width={index === 1 ? columnWidthSmall : columnWidth}
>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell, index}
<TableCell width={index === 1 ? columnWidthSmall : columnWidth}
>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
<FormList>
<InputText
label={`Confirm the organization name to continue`}
placeholder="Enter {$organization.name} to continue"
id="organization-name"
required
bind:value={organizationName} />
</FormList>
{/if}
<FormList>
<InputText
label={`Confirm the organization name to continue`}
placeholder="Enter {$organization.name} to continue"
id="organization-name"
required
bind:value={organizationName} />
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button
secondary
submit
disabled={!error && (!organizationName || organizationName !== $organization.name)}>
Delete
</Button>
{#if estimation && estimation.unpaidInvoices.length > 0}
<Button href={$billingURL} secondary>View invoices</Button>
{:else}
<Button
secondary
submit
disabled={!error &&
(!organizationName || organizationName !== $organization.name)}>
Delete
</Button>
{/if}
</svelte:fragment>
</Modal>
</div>
@@ -0,0 +1,142 @@
<script lang="ts">
import {
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
import { Pill } from '$lib/elements';
import { DropList, DropListItem, DropListLink } from '$lib/components';
import type { Invoice } from '$lib/sdk/billing';
import { getApiEndpoint } from '$lib/stores/sdk';
import { selectedInvoice, showRetryModal } from '../billing/store';
import { toLocaleDate } from '$lib/helpers/date';
import { Button } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import { page } from '$app/stores';
import { trackEvent } from '$lib/actions/analytics';
let showDropdown = [];
let showFailedError = false;
const endpoint = getApiEndpoint();
export let invoices: Invoice[];
export let showActions = true;
function retryPayment(invoice: Invoice) {
$selectedInvoice = invoice;
$showRetryModal = true;
}
</script>
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
{#if showActions}
<TableCellHead width={40} />
{/if}
</TableHeader>
<TableBody>
{#each invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}
button>
{status === 'requires_authentication' ? 'failed' : status}
</Pill>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed.
<Button
link
on:click={() => {
retryPayment(invoice);
}}
>Try again
</Button>
.
</li>
</svelte:fragment>
</DropList>
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
{status === 'requires_authentication' ? 'failed' : status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
{#if showActions}
<TableCell showOverflow right>
<DropList bind:show={showDropdown[i]} placement="bottom-start" noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() => (showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
</TableCell>
{/if}
</TableRow>
{/each}
</TableBody>
</TableScroll>
@@ -1,5 +1,16 @@
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import { derived } from 'svelte/store';
import { derived, writable } from 'svelte/store';
import type { RegionList } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
export const regions = writable<RegionList | undefined>(undefined);
export const regionFlagUrls = derived(regions, ($regions) => {
if (!$regions?.regions?.length) return [];
return $regions?.regions?.map((region) => {
return `${sdk.forConsole.client.config.endpoint}/avatars/flags/${region.flag}?width=80&height=60&quality=100&mode=admin`;
});
});
export const projects = derived(page, ($page) => $page.data?.projects as Models.ProjectList);
@@ -1,6 +1,16 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { trackEvent } from '$lib/actions/analytics';
import { tooltip } from '$lib/actions/tooltip';
import { BarChart, Legend } from '$lib/charts';
import { Card, CardGrid, Heading, ProgressBarBig } from '$lib/components';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { bytesToSize, humanFileSize, mbSecondsToGBHours } from '$lib/helpers/sizeConvertion';
import { formatNum } from '$lib/helpers/string';
import { Container } from '$lib/layout';
import { accumulateFromEndingTotal, total } from '$lib/layout/usage.svelte';
import type { OrganizationUsage } from '$lib/sdk/billing';
import {
getServiceLimit,
showUsageRatesModal,
@@ -8,18 +18,8 @@
upgradeURL
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { Button } from '$lib/elements/forms';
import { bytesToSize, humanFileSize, mbSecondsToGBHours } from '$lib/helpers/sizeConvertion';
import { BarChart } from '$lib/charts';
import ProjectBreakdown from './ProjectBreakdown.svelte';
import { formatNum } from '$lib/helpers/string';
import { accumulateFromEndingTotal, total } from '$lib/layout/usage.svelte';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { BillingPlan } from '$lib/constants';
import { trackEvent } from '$lib/actions/analytics';
import TotalMembers from './totalMembers.svelte';
import { tooltip } from '$lib/actions/tooltip';
import { formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
export let data;
@@ -28,7 +28,12 @@
: (data?.currentInvoice?.plan ?? $organization?.billingPlan);
const plan = data?.plan ?? undefined;
$: project = (data.organizationUsage as OrganizationUsage).projects;
$: projects = (data.organizationUsage as OrganizationUsage).projects;
$: legendData = [
{ name: 'Reads', value: data.organizationUsage.databasesReadsTotal },
{ name: 'Writes', value: data.organizationUsage.databasesWritesTotal }
];
</script>
<Container>
@@ -124,8 +129,8 @@
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="bandwidth" {data} />
{#if projects?.length > 0}
<ProjectBreakdown {projects} metric="bandwidth" {data} />
{/if}
{:else}
<Card isDashed>
@@ -133,7 +138,7 @@
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
style:font-size="32px" />
<p class="u-bold">No data to show</p>
</div>
</Card>
@@ -170,14 +175,130 @@
{
name: 'Users',
data: accumulateFromEndingTotal(
data.usersUsageToDate,
data.organizationUsage.usersTotal
data.organizationUsage.users,
data.organizationUsage.usersTotal,
new Date()
)
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="users" {data} />
{#if projects?.length > 0}
<ProjectBreakdown {projects} metric="users" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">Database reads and writes</Heading>
<p class="text">
The total number of database reads and writes across all projects in your organization.
</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.databasesReads || data.organizationUsage.databasesWrites}
<div style:margin-top="-1.5em" style:margin-bottom="-1em">
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
}
}
}}
series={[
{
name: 'Reads',
data: [
...(data.organizationUsage.databasesReads ?? []).map((e) => [
e.date,
e.value
])
]
},
{
name: 'Writes',
data: [
...(data.organizationUsage.databasesWrites ?? []).map((e) => [
e.date,
e.value
])
]
}
]} />
</div>
<Legend {legendData} />
{#if projects?.length > 0}
<ProjectBreakdown
{data}
{projects}
databaseOperationMetric={['databasesReads', 'databasesWrites']} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">Image transformations</Heading>
<p class="text">
The total number of unique image transformations across all projects in your
organization. <a
href="https://appwrite.io/docs/advanced/platform/image-transformations"
class="link">Learn more</a
>.
</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.imageTransformationsTotal}
{@const current = data.organizationUsage.imageTransformationsTotal}
<ProgressBarBig
currentUnit="Transformations"
currentValue={formatNum(current)}
progressValue={current}
showBar={false} />
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
}
}
}}
series={[
{
name: 'Image transformations',
data: [
...(data.organizationUsage.imageTransformations ?? []).map((e) => [
e.date,
e.value
])
]
}
]} />
{#if projects?.length > 0}
<ProjectBreakdown {projects} metric="imageTransformations" {data} />
{/if}
{:else}
<Card isDashed>
@@ -232,9 +353,10 @@
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="executions" {data} />
{/if}
{#if projects?.length > 0}<ProjectBreakdown
{projects}
metric="executions"
{data} />{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -302,9 +424,10 @@
progressValue={bytesToSize(current, 'GB')}
progressMax={max}
progressBarData={progressBarStorageDate} />
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="storage" {data} />
{/if}
{#if projects?.length > 0}<ProjectBreakdown
{projects}
metric="storage"
{data} />{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -318,6 +441,7 @@
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">GB hours</Heading>
@@ -378,6 +502,7 @@
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">Phone OTP</Heading>
<p class="text">
@@ -387,7 +512,6 @@
class="link">pricing page</a
>.
</p>
<p>You will not be charged for Phone OTPs before February 10th.</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.authPhoneTotal}
<div class="u-flex u-main-space-between">
@@ -410,9 +534,9 @@
</p>
</div>
{#if project?.length > 0}
{#if projects?.length > 0}
<ProjectBreakdown
projects={project}
{projects}
metric="authPhoneTotal"
estimate="authPhoneEstimate"
{data} />
@@ -430,6 +554,7 @@
{/if}
</svelte:fragment>
</CardGrid>
<TotalMembers members={data?.organizationMembers} />
<p class="text common-section u-color-text-gray">
@@ -1,8 +1,8 @@
import type { Invoice } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { Query, type Models } from '@appwrite.io/console';
import type { PageLoad } from './$types';
import { type Organization } from '$lib/stores/organization';
import type { Invoice } from '$lib/sdk/billing';
export const load: PageLoad = async ({ params, parent }) => {
const { invoice } = params;
@@ -29,7 +29,13 @@ export const load: PageLoad = async ({ params, parent }) => {
executionsMBSecondsTotal: null,
buildsMBSecondsTotal: null,
authPhoneTotal: null,
authPhoneEstimate: null
authPhoneEstimate: null,
databasesReads: null,
databasesWrites: null,
databasesReadsTotal: null,
databasesWritesTotal: null,
imageTransformations: null,
imageTransformationsTotal: null
}
};
}
@@ -47,10 +53,10 @@ export const load: PageLoad = async ({ params, parent }) => {
sdk.forConsole.billing.listInvoices(org.$id, [Query.orderDesc('from')]),
sdk.forConsole.billing.listUsage(params.organization, startDate, endDate),
sdk.forConsole.teams.listMemberships(params.organization),
sdk.forConsole.billing.getPlan(org.$id)
sdk.forConsole.billing.getOrganizationPlan(org.$id)
]);
const projectNames: { [key: string]: Models.Project } = {};
const projects: { [key: string]: Models.Project } = {};
if (usage?.projects?.length > 0) {
// in batches of 100 (the max number of values in a query)
const requests = [];
@@ -69,20 +75,17 @@ export const load: PageLoad = async ({ params, parent }) => {
const responses = await Promise.all(requests);
for (const response of responses) {
for (const project of response.projects) {
projectNames[project.$id] = project;
projects[project.$id] = project;
}
}
}
const usersUsageToDate = usage.users.filter((user) => new Date(user.date) < new Date());
return {
organizationUsage: usage,
projectNames,
plan,
invoices,
projects,
currentInvoice,
organizationMembers,
plan,
usersUsageToDate
organizationUsage: usage
};
};
@@ -1,5 +1,5 @@
<script lang="ts">
import type { PageData } from './$types';
import { base } from '$app/paths';
import { Collapsible, CollapsibleItem } from '$lib/components';
import {
TableBody,
@@ -13,16 +13,29 @@
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { base } from '$app/paths';
import { canSeeProjects } from '$lib/stores/roles';
import { onMount } from 'svelte';
import type { PageData } from './$types';
type Metric =
| 'users'
| 'storage'
| 'bandwidth'
| 'executions'
| 'authPhoneTotal'
| 'databasesReads'
| 'databasesWrites'
| 'imageTransformations';
type Metric = 'users' | 'storage' | 'bandwidth' | 'executions' | 'authPhoneTotal';
type Estimate = 'authPhoneEstimate';
type DatabaseOperationMetric = Extract<Metric, 'databasesReads' | 'databasesWrites'>;
export let data: PageData;
export let projects: OrganizationUsage['projects'];
export let metric: Metric;
export let metric: Metric | undefined = undefined;
export let estimate: Estimate | undefined = undefined;
export let databaseOperationMetric: DatabaseOperationMetric[] | undefined = undefined;
function getMetricTitle(metric: Metric): string {
switch (metric) {
@@ -33,31 +46,62 @@
}
}
function getProjectUsageLink(region: string, projectId: string): string {
function getProjectUsageLink(projectId: string): string {
const region = data.projects[projectId]?.region ?? 'fra';
return `${base}/project-${region}-${projectId}/settings/usage`;
}
function getProjectName(projectId: string): string {
return data.projects[projectId]?.name ?? 'Unknown';
}
function groupByProject(
metric: Metric,
estimate?: Estimate
): Array<{ projectId: string; usage: number; estimate?: number }> {
metric: Metric | undefined,
estimate?: Estimate,
databaseOps?: DatabaseOperationMetric[]
): Array<{
projectId: string;
databasesReads?: number;
databasesWrites?: number;
usage?: number;
estimate?: number;
}> {
const data = [];
for (const project of projects) {
const usage = project[metric];
if (!usage) {
continue;
const projectId = project.projectId;
if (metric) {
const usage = project[metric];
if (!usage) continue;
data.push({
projectId,
usage: usage ?? 0,
estimate: estimate ? project[estimate] : undefined
});
} else if (databaseOps) {
const reads = project['databasesReads'] ?? 0;
const writes = project['databasesWrites'] ?? 0;
if (reads || writes) {
data.push({
projectId,
databasesReads: reads,
databasesWrites: writes
});
}
}
data.push({
projectId: project.projectId,
usage: usage ?? 0,
estimate: estimate ? project[estimate] : undefined
});
}
return data;
}
function format(value: number): string {
if (databaseOperationMetric) {
return abbreviateNumber(value);
}
switch (metric) {
case 'imageTransformations':
case 'authPhoneTotal':
return formatNumberWithCommas(value);
case 'executions':
@@ -68,59 +112,100 @@
return humanFileSize(value).value + humanFileSize(value).unit;
}
}
onMount(() => {
if (metric === undefined && databaseOperationMetric === undefined) {
throw new Error(`metric or database operations must be defined`);
}
});
</script>
<Collapsible>
<CollapsibleItem>
<svelte:fragment slot="title">Project breakdown</svelte:fragment>
<TableScroll dense noStyles noMargin style="table-layout: auto">
<TableHeader>
<TableCellHead>Project</TableCellHead>
<TableCellHead>{getMetricTitle(metric)}</TableCellHead>
{#if estimate}
<TableCellHead>Estimated cost</TableCellHead>
{/if}
{#if $canSeeProjects}
<TableCellHead />
{/if}
</TableHeader>
<TableBody>
{#each groupByProject(metric, estimate).sort((a, b) => b.usage - a.usage) as project}
{#if !$canSeeProjects}
<TableRow>
<TableCell title="Project">
{data.projectNames[project.projectId]?.name ?? 'Unknown'}
</TableCell>
<TableCell title={getMetricTitle(metric)}
>{format(project.usage)}</TableCell>
{#if project.estimate}
<TableCell title="Estimated cost"
>{formatCurrency(project.estimate)}</TableCell>
{/if}
</TableRow>
{#if projects.some((project) => project[metric]) || projects.some( (project) => databaseOperationMetric?.some((metric) => project[metric]) )}
<Collapsible>
<CollapsibleItem>
<svelte:fragment slot="title">Project breakdown</svelte:fragment>
<TableScroll dense noStyles noMargin style="table-layout: auto">
<TableHeader>
<TableCellHead>Project</TableCellHead>
{#if databaseOperationMetric}
<TableCellHead>Reads</TableCellHead>
<TableCellHead>Writes</TableCellHead>
{:else}
<!-- TODO: hardcoded region -->
<TableRowLink href={getProjectUsageLink('fra1', project.projectId)}>
<TableCell title="Project">
{data.projectNames[project.projectId]?.name ?? 'Unknown'}
</TableCell>
<TableCell title={getMetricTitle(metric)}
>{format(project.usage)}</TableCell>
{#if project.estimate}
<TableCell title="Estimated cost"
>{formatCurrency(project.estimate)}</TableCell>
{/if}
<TableCell right={true}>
<span
class="icon-cheveron-right u-cross-child-center ignore-icon-rotate" />
</TableCell>
</TableRowLink>
<TableCellHead>{getMetricTitle(metric)}</TableCellHead>
{/if}
{/each}
</TableBody>
</TableScroll>
</CollapsibleItem>
</Collapsible>
{#if estimate}
<TableCellHead>Estimated cost</TableCellHead>
{/if}
{#if $canSeeProjects}
<TableCellHead />
{/if}
</TableHeader>
<TableBody>
{#each groupByProject(metric, estimate, databaseOperationMetric).sort( (a, b) => {
const aValue = a.usage ?? a.databasesReads ?? 0;
const bValue = b.usage ?? b.databasesReads ?? 0;
return bValue - aValue;
} ) as project}
{#if !$canSeeProjects}
<TableRow>
<TableCell title="Project">
{getProjectName(project.projectId)}
</TableCell>
{#if databaseOperationMetric}
<TableCell title="Reads">
{format(project.databasesReads ?? 0)}
</TableCell>
<TableCell title="Writes">
{format(project.databasesWrites ?? 0)}
</TableCell>
{:else}
<TableCell>
{format(project.usage)}
</TableCell>
{/if}
{#if project.estimate}
<TableCell title="Estimated cost">
{formatCurrency(project.estimate)}
</TableCell>
{/if}
</TableRow>
{:else}
<TableRowLink href={getProjectUsageLink(project.projectId)}>
<TableCell title="Project">
{getProjectName(project.projectId)}
</TableCell>
{#if databaseOperationMetric}
<TableCell title="Reads">
{format(project.databasesReads ?? 0)}
</TableCell>
<TableCell title="Writes">
{format(project.databasesWrites ?? 0)}
</TableCell>
{:else}
<TableCell>
{format(project.usage)}
</TableCell>
{/if}
{#if project.estimate}
<TableCell title="Estimated cost">
{formatCurrency(project.estimate)}
</TableCell>
{/if}
<TableCell right={true}>
<span
class="icon-cheveron-right u-cross-child-center ignore-icon-rotate" />
</TableCell>
</TableRowLink>
{/if}
{/each}
</TableBody>
</TableScroll>
</CollapsibleItem>
</Collapsible>
{/if}
<style>
.ignore-icon-rotate {
@@ -64,9 +64,11 @@
<TableRow>
<TableCell title="name">
<div class="u-flex u-gap-12 u-cross-center">
<AvatarInitials size={32} name={member.userName} />
<AvatarInitials
size={32}
name={member.userName || member.userEmail} />
<span class="text u-trim">
{member.userName ? member.userName : member.userEmail}
{member.userName || member.userEmail}
</span>
</div>
</TableCell>
@@ -1,17 +1,26 @@
<script lang="ts">
import { page } from '$app/stores';
import { CustomId } from '$lib/components';
import { Pill } from '$lib/elements';
import { FormList, InputText } from '$lib/elements/forms';
import { InputText, FormList } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { createProject } from './store';
import { loadAvailableRegions } from '$routes/(console)/regions';
import { regionFlagUrls, regions } from '../store';
import { organization } from '$lib/stores/organization';
let showCustomId = false;
loadAvailableRegions($page.params.organization);
if (!$regions?.regions) {
sdk.forConsole.billing.listRegions($organization.$id).then(regions.set);
}
</script>
<svelte:head>
{#each $regionFlagUrls as image}
<link rel="preload" as="image" href={image} />
{/each}
</svelte:head>
<WizardStep>
<svelte:fragment slot="title">Details</svelte:fragment>
<FormList>
@@ -9,7 +9,7 @@
import { addNotification } from '$lib/stores/notifications';
import type { Models } from '@appwrite.io/console';
import { page } from '$app/stores';
import { regions } from '$lib/stores/organization';
import { regions } from '$routes/(console)/organization-[organization]/store';
let prefs: Models.Preferences;
@@ -79,13 +79,15 @@
return 1;
}
return -1;
}) as region}
}) as region, index}
<li>
<RegionCard
name="region"
bind:group={$createProject.region}
value={region.$id}
disabled={region.disabled}>
disabled={region.disabled}
autofocus={index === 0}>
<!-- focus first item so enter key works! -->
<div
class="u-flex u-flex-vertical u-gap-8 u-justify-main-center u-cross-center u-margin-inline-auto">
{#if region.disabled}
@@ -8,6 +8,9 @@ import { isCloud } from '$lib/system';
import type { Organization } from '$lib/stores/organization';
import { defaultRoles, defaultScopes } from '$lib/constants';
import type { Plan } from '$lib/sdk/billing';
import { get } from 'svelte/store';
import { headerAlert } from '$lib/stores/headerAlert';
import PaymentFailed from '$lib/components/billing/alerts/paymentFailed.svelte';
import { loadAvailableRegions } from '$routes/(console)/regions';
export const load: LayoutLoad = async ({ params, depends }) => {
@@ -31,12 +34,20 @@ export const load: LayoutLoad = async ({ params, depends }) => {
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
if (isCloud) {
currentPlan = await sdk.forConsole.billing.getPlan(project.teamId);
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(project.teamId);
const res = await sdk.forConsole.billing.getRoles(project.teamId);
roles = res.roles;
scopes = res.scopes;
if (scopes.includes('billing.read')) {
await failedInvoice.load(project.teamId);
if (get(failedInvoice)) {
headerAlert.add({
show: true,
component: PaymentFailed,
id: 'paymentFailed',
importance: 1
});
}
}
}
@@ -13,6 +13,8 @@
import type { Models } from '@appwrite.io/console';
import { project } from '../../store';
import { base } from '$app/paths';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
const projectId = $page.params.project;
let showProvider = false;
@@ -30,6 +32,7 @@
method: box.method,
value: box.value
});
invalidate(Dependencies.PROJECT);
} catch (error) {
box.value = !box.value;
addNotification({
@@ -1,7 +1,7 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
@@ -41,7 +41,7 @@
state="warning"
headerDivider={false}>
<p data-private>
Are you sure you want to delete <b>all of {$user.name}'s sessions?</b>
Are you sure you want to delete <b>all of {$user.name || 'User'}'s sessions?</b>
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDeleteAll = false)}>Cancel</Button>
@@ -2,13 +2,13 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { user } from './store';
import { project } from '../../store';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { user } from './store';
export let showDelete = false;
let error: string;
@@ -37,7 +37,9 @@
state="warning"
headerDivider={false}
bind:error>
<p data-private>Are you sure you want to delete <b>{$user.name}</b> from '{$project.name}'?</p>
<p data-private>
Are you sure you want to delete <b>{$user.name || 'User'}</b> from '{$project.name}'?
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
@@ -1,6 +1,6 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputText } from '$lib/elements/forms';
@@ -12,7 +12,7 @@
let userName: string = null;
onMount(async () => {
userName ??= $user.name;
userName ??= $user.name ?? '';
});
async function updateName() {
@@ -5,8 +5,10 @@ import { type Models, Query } from '@appwrite.io/console';
import { timeFromNow } from '$lib/helpers/date';
import type { PageLoad, RouteParams } from './$types';
import type { BackupPolicy } from '$lib/sdk/backups';
import { isCloud } from '$lib/system';
import type { Plan } from '$lib/sdk/billing';
export const load: PageLoad = async ({ url, route, depends, params }) => {
export const load: PageLoad = async ({ url, route, depends, params, parent }) => {
depends(Dependencies.DATABASES);
const page = getPage(url);
@@ -14,10 +16,14 @@ export const load: PageLoad = async ({ url, route, depends, params }) => {
const view = getView(params.project, url, route, View.Grid);
const offset = pageToOffset(page, limit);
// already loaded by parent.
const { currentPlan } = await parent();
const { databases, policies, lastBackups } = await fetchDatabasesAndBackups(
limit,
offset,
params
params,
currentPlan
);
return {
@@ -30,16 +36,26 @@ export const load: PageLoad = async ({ url, route, depends, params }) => {
};
};
// TODO: @itznotabug we should improve this!
async function fetchDatabasesAndBackups(limit: number, offset: number, params: RouteParams) {
async function fetchDatabasesAndBackups(
limit: number,
offset: number,
params: RouteParams,
currentPlan?: Plan
) {
const backupsEnabled = currentPlan?.backupsEnabled ?? true;
const databases = await sdk
.forProject(params.region, params.project)
.databases.list([Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt')]);
const [policies, lastBackups] = await Promise.all([
await fetchPolicies(databases, params),
await fetchLastBackups(databases, params)
]);
let lastBackups: Record<string, string>, policies: Record<string, BackupPolicy[]>;
if (isCloud && backupsEnabled) {
[policies, lastBackups] = await Promise.all([
fetchPolicies(databases, params),
fetchLastBackups(databases, params)
]);
}
return { databases, policies, lastBackups };
}
@@ -3,8 +3,9 @@ import { CARD_LIMIT, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { BackupArchive, BackupArchiveList, BackupPolicyList } from '$lib/sdk/backups';
import { isCloud } from '$lib/system';
export const load = async ({ params, url, route, depends }) => {
export const load = async ({ params, url, route, depends, parent }) => {
depends(Dependencies.BACKUPS);
const page = getPage(url);
const limit = getLimit(params.project, url, route, CARD_LIMIT);
@@ -14,28 +15,34 @@ export const load = async ({ params, url, route, depends }) => {
let backups: BackupArchiveList = { total: 0, archives: [] };
let policies: BackupPolicyList = { total: 0, policies: [] };
try {
[backups, policies] = await Promise.all([
sdk
.forProject(params.region, params.project)
.backups.listArchives([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
]),
// already loaded by parent.
const { currentPlan } = await parent();
const backupsEnabled = currentPlan?.backupsEnabled ?? true;
sdk
.forProject(params.region, params.project)
.backups.listPolicies([
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
])
]);
} catch (e) {
// ignore
if (isCloud && backupsEnabled) {
try {
[backups, policies] = await Promise.all([
sdk
.forProject(params.region, params.project)
.backups.listArchives([
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
]),
sdk
.forProject(params.region, params.project)
.backups.listPolicies([
Query.orderDesc('$createdAt'),
Query.equal('resourceType', 'database'),
Query.equal('resourceId', params.database)
])
]);
} catch (e) {
// ignore
}
}
const archivesByPolicy = groupArchivesByPolicy(backups.archives);
@@ -147,7 +147,7 @@
<TableCellHead width={192}>Backups</TableCellHead>
<TableCellHead width={80}>Size</TableCellHead>
<TableCellHead width={120}>Status</TableCellHead>
<TableCellHead width={80}>Policy</TableCellHead>
<TableCellHead width={120}>Policy</TableCellHead>
<TableCellHead width={48} />
</TableHeader>
<TableBody>
@@ -39,9 +39,9 @@
collectionId,
originalKey,
data.required,
data.default,
data.min,
data.max,
data.default,
data.key !== originalKey ? data.key : undefined
);
}
@@ -39,9 +39,9 @@
collectionId,
originalKey,
data.required,
data.min,
data.max,
data.default,
Math.abs(data.min) > Number.MAX_SAFE_INTEGER ? undefined : data.min,
Math.abs(data.max) > Number.MAX_SAFE_INTEGER ? undefined : data.max,
data.key !== originalKey ? data.key : undefined
);
}
@@ -51,6 +51,8 @@
import { createConservative } from '$lib/helpers/stores';
import { InputNumber, InputChoice } from '$lib/elements/forms';
export let editing = false;
export let data: Partial<Models.AttributeInteger> = {
required: false,
min: 0,
@@ -58,7 +60,6 @@
default: 0,
array: false
};
export let editing = false;
let savedDefault = data.default;
@@ -2,7 +2,7 @@
import { get } from 'svelte/store';
import { page } from '$app/stores';
import { sdk } from '$lib/stores/sdk';
import { ID, Query, type Models, RelationshipType, RelationMutate } from '@appwrite.io/console';
import { ID, type Models, Query, RelationMutate, RelationshipType } from '@appwrite.io/console';
export async function submitRelationship(
databaseId: string,
@@ -16,6 +16,7 @@
if (!isValueOfStringEnum(RelationMutate, data.onDelete)) {
throw new Error(`Invalid on delete: ${data.onDelete}`);
}
const $page = get(page);
await sdk
.forProject($page.params.region, $page.params.project)
@@ -40,6 +41,7 @@
if (!isValueOfStringEnum(RelationMutate, data.onDelete)) {
throw new Error(`Invalid on delete: ${data.onDelete}`);
}
const $page = get(page);
await sdk
.forProject($page.params.region, $page.params.project)
@@ -90,17 +92,10 @@
// Lifecycle hooks
async function getCollections(search: string = null) {
if (search) {
const collections = await sdk
.forProject($page.params.region, $page.params.project)
.databases.listCollections(databaseId, [Query.orderDesc('')], search);
return collections;
} else {
const collections = await sdk
.forProject($page.params.region, $page.params.project)
.databases.listCollections(databaseId);
return collections;
}
const queries = search ? [Query.orderDesc('')] : [Query.limit(100)];
return sdk
.forProject($page.params.region, $page.params.project)
.databases.listCollections(databaseId, queries, search);
}
function updateKeyName() {
@@ -1,21 +1,42 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Usage } from '$lib/layout';
import { Usage, UsageMultiple } from '$lib/layout';
import type { PageData } from './$types';
export let data: PageData;
$: total = data.collectionsTotal;
$: count = data.collections;
$: reads = data.databaseReads;
$: readsTotal = data.databaseReadsTotal;
$: writes = data.databaseWrites;
$: writesTotal = data.databaseWritesTotal;
$: path = `${base}/project-${$page.params.region}-${$page.params.project}/databases/database-${$page.params.database}/usage`;
</script>
<Usage
title="Databases"
path={`${base}/project-${$page.params.region}-${$page.params.project}/databases/database-${$page.params.database}/usage`}
{total}
{count}
countMetadata={{
legend: 'Collections',
title: 'Total collections'
}} />
<div class="u-flex u-flex-vertical u-gap-16">
<Usage
{path}
{total}
{count}
title="Usage"
countMetadata={{
legend: 'Collections',
title: 'Total collections'
}} />
<UsageMultiple
title="Reads and writes"
showHeader={false}
overlapContainerCover
total={[readsTotal, writesTotal]}
count={[reads, writes]}
legendData={[
{ name: 'Reads', value: readsTotal },
{ name: 'Writes', value: writesTotal }
]} />
</div>
@@ -1,20 +1,41 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Usage } from '$lib/layout';
import { Usage, UsageMultiple } from '$lib/layout';
import type { PageData } from './$types';
export let data: PageData;
$: total = data.databasesTotal;
$: count = data.databases;
$: reads = data.databasesReads;
$: readsTotal = data.databasesReadsTotal;
$: writes = data.databasesWrites;
$: writesTotal = data.databasesWritesTotal;
$: path = `${base}/project-${$page.params.region}-${$page.params.project}/databases/usage`;
</script>
<Usage
title="Databases"
path={`${base}/project-${$page.params.region}-${$page.params.project}/databases/usage`}
{total}
{count}
countMetadata={{
legend: 'Databases',
title: 'Total databases'
}} />
<div class="u-flex u-flex-vertical u-gap-16">
<Usage
{path}
{total}
{count}
title="Usage"
countMetadata={{
legend: 'Databases',
title: 'Total databases'
}} />
<UsageMultiple
title="Reads and writes"
showHeader={false}
overlapContainerCover
total={[readsTotal, writesTotal]}
count={[reads, writes]}
legendData={[
{ name: 'Reads', value: readsTotal },
{ name: 'Writes', value: writesTotal }
]} />
</div>
@@ -63,6 +63,14 @@
{ value: 'vcs', label: 'Git' }
]
},
{
id: '$createdAt',
title: 'Created',
type: 'datetime',
show: true,
width: 150,
format: 'datetime'
},
{
id: '$updatedAt',
title: 'Updated',
@@ -71,7 +79,6 @@
width: 150,
format: 'datetime'
},
{
id: 'buildTime',
title: 'Build time',
@@ -0,0 +1,17 @@
<script lang="ts">
import { timeFromNow } from '$lib/helpers/date';
import type { Models } from '@appwrite.io/console';
export let type: 'create' | 'update';
export let deployment: Models.Deployment;
let variable = type === 'create' ? '$createdAt' : '$updatedAt';
</script>
{timeFromNow(deployment[variable])}
{#if deployment.providerCommitAuthor}
by <a
class="u-underline u-cursor-pointer"
target="_blank"
href={deployment.providerCommitAuthorUrl}>{deployment.providerCommitAuthor}</a>
{/if}
@@ -5,7 +5,7 @@
import { Pill } from '$lib/elements';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { calculateTime } from '$lib/helpers/timeConversion';
import DeploymentCreatedBy from './deploymentCreatedBy.svelte';
import DeploymentBy from './deploymentBy.svelte';
import DeploymentSource from './deploymentSource.svelte';
import DeploymentDomains from './deploymentDomains.svelte';
@@ -80,7 +80,7 @@
<li class="u-flex-vertical u-gap-4">
<p class="u-color-text-offline">Updated</p>
<p class="u-line-height-2">
<DeploymentCreatedBy {deployment} />
<DeploymentBy {deployment} type="update" />
</p>
</li>
</ul>
@@ -1,14 +0,0 @@
<script lang="ts">
import { timeFromNow } from '$lib/helpers/date';
import type { Models } from '@appwrite.io/console';
export let deployment: Models.Deployment;
</script>
{timeFromNow(deployment.$updatedAt)}
{#if deployment.providerCommitAuthor}
by <a
class="u-underline u-cursor-pointer"
target="_blank"
href={deployment.providerCommitAuthorUrl}>{deployment.providerCommitAuthor}</a>
{/if}

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