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