mirror of
https://github.com/appwrite/console.git
synced 2026-04-07 19:17:46 +00:00
Merge pull request #1746 from appwrite/sdk-context-main-merge
Merge `main` for `fix-sdk-context`
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+12
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -80,7 +80,9 @@
|
||||
on:cancel|preventDefault
|
||||
{style}>
|
||||
{#if show}
|
||||
<slot close={closeModal} />
|
||||
<div class="content">
|
||||
<slot close={closeModal} />
|
||||
</div>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let name: string;
|
||||
export let group: string;
|
||||
export let value: string | number | boolean;
|
||||
export let disabled = false;
|
||||
export let padding = 1;
|
||||
export let autofocus = false;
|
||||
export let fullHeight = true;
|
||||
export let borderRadius: 'xsmall' | 'small' | 'medium' | 'large' = 'small';
|
||||
|
||||
@@ -13,9 +16,18 @@
|
||||
medium = '--border-radius-medium',
|
||||
large = '--border-radius-large'
|
||||
}
|
||||
|
||||
let labelReference: HTMLLabelElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (autofocus) {
|
||||
labelReference?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label
|
||||
bind:this={labelReference}
|
||||
class="box u-cursor-pointer u-flex u-flex-vertical u-gap-16"
|
||||
class:is-allow-focus={!disabled}
|
||||
class:is-disabled={disabled}
|
||||
|
||||
@@ -2,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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -67,6 +67,8 @@
|
||||
const step = e.detail;
|
||||
if (step < $wizard.step) {
|
||||
$wizard.step = step;
|
||||
// clear the interceptor
|
||||
wizard.setInterceptor(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Exit Process"
|
||||
title="Exit process"
|
||||
bind:show
|
||||
onSubmit={handleSubmit}
|
||||
icon="exclamation"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+14
@@ -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 />
|
||||
|
||||
+17
@@ -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}
|
||||
+119
-109
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
+150
-65
@@ -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 {
|
||||
|
||||
+4
-2
@@ -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({
|
||||
|
||||
+2
-2
@@ -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 };
|
||||
}
|
||||
|
||||
+29
-22
@@ -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);
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -39,9 +39,9 @@
|
||||
collectionId,
|
||||
originalKey,
|
||||
data.required,
|
||||
data.default,
|
||||
data.min,
|
||||
data.max,
|
||||
data.default,
|
||||
data.key !== originalKey ? data.key : undefined
|
||||
);
|
||||
}
|
||||
|
||||
+4
-3
@@ -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;
|
||||
|
||||
|
||||
+7
-12
@@ -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() {
|
||||
|
||||
+31
-10
@@ -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>
|
||||
|
||||
+31
-10
@@ -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>
|
||||
|
||||
+8
-1
@@ -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',
|
||||
|
||||
+17
@@ -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}
|
||||
+2
-2
@@ -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>
|
||||
|
||||
-14
@@ -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
Reference in New Issue
Block a user