Merge remote-tracking branch 'origin/main' into feat-project-changes

This commit is contained in:
Damodar Lohani
2025-06-09 05:45:07 +00:00
257 changed files with 4029 additions and 5578 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
PUBLIC_CONSOLE_MODE=self-hosted
PUBLIC_CONSOLE_FEATURE_FLAGS=
PUBLIC_APPWRITE_MULTI_REGION=false
PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1
PUBLIC_STRIPE_KEY=
PUBLIC_GROWTH_ENDPOINT=
+4
View File
@@ -39,6 +39,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
@@ -78,6 +79,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,csv-import"
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -116,6 +118,7 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=self-hosted"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,csv-import"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
publish-cloud-no-regions:
@@ -153,5 +156,6 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,csv-import"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
+2
View File
@@ -21,6 +21,7 @@ ADD ./src /app/src
ADD ./static /app/static
ARG PUBLIC_CONSOLE_MODE
ARG PUBLIC_CONSOLE_FEATURE_FLAGS
ARG PUBLIC_APPWRITE_MULTI_REGION
ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
@@ -31,6 +32,7 @@ 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_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS
ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
+1
View File
@@ -24,6 +24,7 @@ async function main() {
log(bold().magenta('APPWRITE CONSOLE'));
log();
logEnv('CONSOLE MODE', env?.PUBLIC_CONSOLE_MODE);
logEnv('CONSOLE FEATURE FLAGS', env?.PUBLIC_CONSOLE_FEATURE_FLAGS);
logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION);
logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative');
logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT);
+2
View File
@@ -5,6 +5,7 @@ services:
context: .
args:
PUBLIC_CONSOLE_MODE: ${PUBLIC_CONSOLE_MODE}
PUBLIC_CONSOLE_FEATURE_FLAGS: ${PUBLIC_CONSOLE_FEATURE_FLAGS}
PUBLIC_APPWRITE_MULTI_REGION: ${PUBLIC_APPWRITE_MULTI_REGION}
PUBLIC_APPWRITE_ENDPOINT: ${PUBLIC_APPWRITE_ENDPOINT}
PUBLIC_GROWTH_ENDPOINT: ${PUBLIC_GROWTH_ENDPOINT}
@@ -21,6 +22,7 @@ services:
- build/
environment:
- PUBLIC_CONSOLE_MODE
- PUBLIC_CONSOLE_FEATURE_FLAGS
- PUBLIC_APPWRITE_MULTI_REGION
- PUBLIC_APPWRITE_ENDPOINT
- PUBLIC_GROWTH_ENDPOINT
+10 -1
View File
@@ -4,7 +4,6 @@ server {
gzip_static on;
# Set root for all locations
root /usr/share/nginx/html;
# Security headers for all locations
@@ -22,10 +21,20 @@ server {
add_header Cache-Control "public";
}
# Cache, but revalidate, for images, css, fonts, and icons folders
location ~* ^/console/(images|css|fonts|icons)/ {
expires 1d;
add_header Cache-Control "public, must-revalidate";
}
# All other /console requests (no cache)
location /console {
index index.html index.html;
try_files $uri /console/index.html;
expires 0;
add_header Cache-Control "no-cache, no-store";
add_header Pragma "no-cache";
}
location / {
+2 -2
View File
@@ -23,11 +23,10 @@
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "^2.0.0-RC.1",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://try-module.cloud/module/@appwrite/@appwrite.io/pink-svelte@90cb757",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4e03f34",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@vitest/ui": "^3.0.9",
"color": "^5.0.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.0",
"eslint-plugin-svelte": "^3.3.3",
+39 -22
View File
@@ -14,9 +14,6 @@ importers:
'@appwrite.io/console':
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19
'@appwrite.io/pink':
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
@@ -27,8 +24,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://try-module.cloud/module/@appwrite/@appwrite.io/pink-svelte@90cb757
version: https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-svelte@90cb757(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4e03f34
version: https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@4e03f34(svelte@5.25.3)
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -138,6 +135,9 @@ importers:
'@vitest/ui':
specifier: ^3.0.9
version: 3.0.9(vitest@3.0.9)
color:
specifier: ^5.0.0
version: 5.0.0
eslint:
specifier: ^9.23.0
version: 9.23.0
@@ -281,15 +281,12 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-svelte@90cb757':
resolution: {tarball: https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-svelte@90cb757}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@4e03f34':
resolution: {tarball: https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@4e03f34}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink@0.25.0':
resolution: {integrity: sha512-9z4G/iAhjvVpM7tkNqzGqrokst4hvNt3Fkf2jD4W6n8j//EJbQ3mJ0I+5dye5Sry0a33wUanq5DSztqFNYq5zw==}
'@asamuzakjp/css-color@3.1.1':
resolution: {integrity: sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA==}
@@ -1720,9 +1717,25 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.0:
resolution: {integrity: sha512-TVoqAq8ZDIpK5lsQY874DDnu65CSsc9vzq0wLpNQ6UMBq81GSZocVazPiBbYGzngzBOIRahpkTzCLVe2at4MfA==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.0.0:
resolution: {integrity: sha512-SbtvAMWvASO5TE2QP07jHBMXKafgdZz8Vrsrn96fiL+O92/FN/PLARzUW5sKt013fjAprK2d2iCn2hk2Xb5oow==}
engines: {node: '>=12.20'}
color-string@2.0.1:
resolution: {integrity: sha512-5z9FbYTZPAo8iKsNEqRNv+OlpBbDcoE+SY9GjLfDUHEfcNNV7tS9eSAlFHEaub/r5tBL9LtskAeq1l9SaoZ5tQ==}
engines: {node: '>=18'}
color@5.0.0:
resolution: {integrity: sha512-16BlyiuyLq3MLxpRWyOTiWsO3ii/eLQLJUQXBSNcxMBBSnyt1ee9YUdaozQp03ifwm5woztEZGDbk9RGVuCsdw==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -2664,9 +2677,6 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize.css@8.0.1:
resolution: {integrity: sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==}
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
@@ -3644,7 +3654,7 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://try-module.cloud/module/@appwrite/%40appwrite.io%2Fpink-svelte@90cb757(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@4e03f34(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
@@ -3659,12 +3669,6 @@ snapshots:
svelte-motion: 0.12.2(svelte@5.25.3)
svelte-sonner: 0.3.28(svelte@5.25.3)
'@appwrite.io/pink@0.25.0':
dependencies:
'@appwrite.io/pink-icons': 0.25.0
normalize.css: 8.0.1
the-new-css-reset: 1.11.3
'@asamuzakjp/css-color@3.1.1':
dependencies:
'@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)
@@ -5271,8 +5275,23 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.0:
dependencies:
color-name: 2.0.0
color-name@1.1.4: {}
color-name@2.0.0: {}
color-string@2.0.1:
dependencies:
color-name: 2.0.0
color@5.0.0:
dependencies:
color-convert: 3.1.0
color-string: 2.0.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -6254,8 +6273,6 @@ snapshots:
normalize-path@3.0.0: {}
normalize.css@8.0.1: {}
nwsapi@2.2.20: {}
object-inspect@1.13.4: {}
+12
View File
@@ -1,2 +1,14 @@
/// <reference types="@sveltejs/kit" />
interface Window {}
namespace App {
interface Error {
type?: string;
}
interface HandleClientError {
message: string;
status?: number;
type?: string;
}
}
+5 -1
View File
@@ -13,13 +13,17 @@ Sentry.init({
export const handleError: HandleClientError = ({ error, message, status }) => {
console.error(error);
let type;
if (error instanceof AppwriteException) {
status = error.code === 0 ? undefined : error.code;
message = error.message;
type = error.type;
}
return {
message,
status
status,
type
};
};
+14 -5
View File
@@ -11,6 +11,7 @@
import { IconArrowSmRight } from '@appwrite.io/pink-icons-svelte';
import { Icon, Keyboard, Layout } from '@appwrite.io/pink-svelte';
import { Submit, trackEvent } from '$lib/actions/analytics';
import type { Action } from 'svelte/action';
/* eslint no-undef: "off" */
type Option = $$Generic<Omit<Command, 'group'> & { group?: string }>;
@@ -247,6 +248,10 @@
return isGroup(nextItem) || !nextItem?.nested;
};
const autofocus: Action<HTMLInputElement> = (node) => {
if (node) node.focus();
};
</script>
<svelte:window on:keydown={handleKeyDown} />
@@ -271,8 +276,11 @@
<slot name="search">
<div class="u-flex default-search u-width-full-line">
<!-- svelte-ignore a11y-autofocus -->
<input type="text" placeholder={searchPlaceholder} autofocus bind:value={search} />
<input
type="text"
placeholder={searchPlaceholder}
use:autofocus
bind:value={search} />
</div>
</slot>
</div>
@@ -305,7 +313,7 @@
on:mouseleave={getOptionBlurHandler()}
on:focus={getOptionFocusHandler(item)}>
<slot name="option" option={castOption(item)}>
<Layout.Stack direction="column" gap="s">
<Layout.Stack direction="row" gap="s" alignItems="center">
{#if item.icon}
<Icon
icon={item.icon}
@@ -448,8 +456,9 @@
.default-search {
input {
margin: -1rem;
padding: 1rem;
width: 100%;
margin: 0rem;
padding: 0rem;
border: none;
background-color: transparent;
}
@@ -7,6 +7,8 @@
import { organization } from '$lib/stores/organization';
import { activeHeaderAlert } from '$routes/(console)/store';
import GradientBanner from '../gradientBanner.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
let show = true;
@@ -23,8 +25,13 @@
{#if show && $organization?.$id && $organization?.billingPlan === BillingPlan.FREE && !page.url.pathname.includes('/console/account')}
<GradientBanner on:close={handleClose}>
<div class="u-flex u-gap-24 u-main-center u-cross-center u-flex-vertical-mobile">
<span class="body-text-1">Get $50 Cloud credits for Appwrite Pro.</span>
<Layout.Stack
gap="m"
alignItems="center"
alignContent="center"
direction={$isSmallViewport ? 'column' : 'row'}>
<Typography.Text>Get $50 Cloud credits for Appwrite Pro.</Typography.Text>
<Button
secondary
fullWidthMobile
@@ -39,6 +46,6 @@
}}>
Claim credits
</Button>
</div>
</Layout.Stack>
</GradientBanner>
{/if}
@@ -1,53 +1,61 @@
<script lang="ts">
import { formatCurrency } from '$lib/helpers/numbers';
import { Button } from '$lib/elements/forms';
import type { Coupon } from '$lib/sdk/billing';
import { Tooltip } from '@appwrite.io/pink-svelte';
import { formatCurrency } from '$lib/helpers/numbers';
import { IconTag, IconX } from '@appwrite.io/pink-icons-svelte';
import { Badge, Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
export let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
export let fixedCoupon = false;
</script>
{#if couponData?.credits}
<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>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack inline direction="row" gap="s" alignItems="center" alignContent="center">
<Layout.Stack
inline
direction="row"
gap="xxs"
alignItems="center"
alignContent="center">
<Icon icon={IconTag} color="--fgcolor-success" size="s" />
{#if couponData.credits >= 100}
{couponData?.code?.toUpperCase()}
{:else}
<span
><Tooltip
>Credits applied <span slot="tooltip"
>{couponData?.code?.toUpperCase()}</span
></Tooltip
></span>
<Tooltip>
<Typography.Text color="--fgcolor-neutral-primary"
>Credits applied</Typography.Text>
<span slot="tooltip">{couponData?.code?.toUpperCase()}</span>
</Tooltip>
{/if}
</p>
</Layout.Stack>
{#if !fixedCoupon}
<button
type="button"
class="button is-text is-only-icon"
style="--button-size:1.5rem;"
aria-label="Close"
title="Close"
on:click={() =>
(couponData = {
<Button
extraCompact
icon
on:click={() => {
couponData = {
code: null,
status: null,
credits: null
})}>
<span class="icon-x" aria-hidden="true"></span>
</button>
};
}}>
<Icon icon={IconX} size="s" />
</Button>
{/if}
</div>
</Layout.Stack>
{#if couponData.credits >= 100}
<p class="inline-tag">Credits applied</p>
<Badge variant="secondary" content="Credits applied" />
{:else}
<span class="u-color-text-success">-{formatCurrency(couponData.credits)}</span>
<Typography.Text color="--fgcolor-success"
>-{formatCurrency(couponData.credits)}</Typography.Text>
{/if}
</span>
</Layout.Stack>
{/if}
@@ -1,8 +1,8 @@
<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';
import { formatCurrency } from '$lib/helpers/numbers';
import { IconTag } from '@appwrite.io/pink-icons-svelte';
import { Badge, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
export let label: string;
export let value: number;
@@ -11,39 +11,27 @@
status: null,
credits: null
};
export let fixedCoupon = false;
let adjustedValue = value;
$: if (label?.toLowerCase() === 'credits' && couponData?.status === 'active') {
adjustedValue = value - (couponData?.credits || 0);
} else {
adjustedValue = value;
}
</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 adjustedValue > 0}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack inline direction="row" gap="xxs" alignItems="center" alignContent="center">
<Icon icon={IconTag} color="--fgcolor-success" size="s" />
<Typography.Text color="--fgcolor-neutral-primary">{label}</Typography.Text>
</Layout.Stack>
{#if value >= 100}
<p class="inline-tag">Credits applied</p>
<Badge variant="secondary" content="Credits applied" />
{:else}
<span class="u-color-text-success">-{formatCurrency(value)}</span>
<Typography.Text color="--fgcolor-success"
>-{formatCurrency(adjustedValue)}</Typography.Text>
{/if}
</span>
</Layout.Stack>
{/if}
@@ -1,144 +0,0 @@
<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}
@@ -1,96 +1,145 @@
<script lang="ts">
import { InputChoice, InputNumber } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon, PlansMap } from '$lib/sdk/billing';
import type { Coupon, Estimation } from '$lib/sdk/billing';
import { type Tier } from '$lib/stores/billing';
import { Card, Divider, Layout, Typography } from '@appwrite.io/pink-svelte';
import { CreditsApplied } from '.';
import { sdk } from '$lib/stores/sdk';
import { AppwriteException } from '@appwrite.io/console';
import DiscountsApplied from './discountsApplied.svelte';
export let billingPlan: Tier;
export let collaborators: string[];
export let couponData: Partial<Coupon>;
export let plans: PlansMap;
export let billingBudget: number;
export let fixedCoupon = false; // If true, the coupon cannot be removed
export let isDowngrade = false;
const today = new Date();
const billingPayDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
export let organizationId: string | undefined = undefined;
let budgetEnabled = false;
let estimation: Estimation;
$: currentPlan = plans.get(billingPlan);
$: extraSeatsCost = 0; // 0 untile trial period later replace (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: grossCost = currentPlan.price + extraSeatsCost;
$: estimatedTotal =
couponData?.status === 'active'
? grossCost - couponData.credits >= 0
? grossCost - couponData.credits
: 0
: grossCost;
$: trialEndDate = new Date(
billingPayDate.getTime() + currentPlan.trialDays * 24 * 60 * 60 * 1000
);
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' ||
e.type === 'billing_coupon_not_eligible'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
}
}
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' ||
e.type === 'billing_coupon_not_eligible'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
}
}
$: organizationId
? getUpdatePlanEstimate(organizationId, billingPlan, collaborators, couponData?.code)
: getEstimate(billingPlan, collaborators, couponData?.code);
</script>
<Card.Base padding="s">
<Layout.Stack>
<slot />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text>{currentPlan.name} plan</Typography.Text>
<Typography.Text>{formatCurrency(currentPlan.price)}</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>Additional seats ({collaborators?.length ?? 0})</Typography.Text>
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{formatCurrency(extraSeatsCost)}</Typography.Text>
</Layout.Stack>
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
{#if estimation}
<Card.Base padding="s">
<Layout.Stack>
<slot />
{#each estimation.items ?? [] as item}
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{item.label}</Typography.Text>
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{formatCurrency(item.value)}</Typography.Text>
</Layout.Stack>
{/each}
{#each estimation.discounts ?? [] as item}
<DiscountsApplied {couponData} {...item} />
{/each}
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<Divider />
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text>Total due</Typography.Text>
<Typography.Text>
{formatCurrency(estimation.grossAmount)}
</Typography.Text>
</Layout.Stack>
<Typography.Text>
Upcoming charge<br />
Due on {!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</Typography.Text>
<Typography.Text>{formatCurrency(estimatedTotal)}</Typography.Text>
You'll pay <b>{formatCurrency(estimation.grossAmount)}</b>
now.
{#if couponData?.code}Once your credits run out,{:else}Then{/if} you'll be charged
<b>{formatCurrency(estimation.amount)}</b> every 30 days.
</Typography.Text>
<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
required
autofocus
id="budget"
label="Budget cap (USD)"
placeholder="0"
min={0}
bind:value={billingBudget} />
</div>
{/if}
</InputChoice>
</Layout.Stack>
<Typography.Text>
You'll pay <b>{formatCurrency(estimatedTotal)}</b> now, with your first billing cycle
starting on
<b
>{!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</b
>. {#if couponData?.status === 'active'}Once your credits run out, you'll be charged
<b>{formatCurrency(currentPlan.price)}</b> plus usage fees every 30 days.
{/if}
</Typography.Text>
<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
required
autofocus
id="budget"
label="Budget cap (USD)"
placeholder="0"
min={0}
bind:value={billingBudget} />
</div>
{/if}
</InputChoice>
</Layout.Stack>
</Card.Base>
</Card.Base>
{/if}
@@ -1,11 +1,41 @@
<script lang="ts">
import { base } from '$app/paths';
import { createEventDispatcher } from 'svelte';
import { isTabletViewport } from '$lib/stores/viewport';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { Button } from '$lib/elements/forms';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconX } from '@appwrite.io/pink-icons-svelte';
let container: HTMLElement;
const dispatch = createEventDispatcher();
const queryLayoutElements = () => ({
header: document.querySelector<HTMLElement>('main > header'),
sidebar: document.querySelector<HTMLElement>('main > div > nav'),
content: document.querySelector<HTMLElement>('main > div > section')
});
const setNavigationHeight = () => {
const alertHeight = container?.getBoundingClientRect()?.height || 0;
const { header, sidebar, content } = queryLayoutElements();
const headerHeight = header?.getBoundingClientRect().height || 0;
const offset = alertHeight + (!isTabletViewport && header ? headerHeight : 0);
if (header) header.style.top = `${alertHeight}px`;
if (sidebar) {
sidebar.style.top = `${offset}px`;
sidebar.style.height = `calc(100vh - ${offset}px)`;
}
if (content) content.style.paddingBlockStart = `${alertHeight}px`;
};
onMount(setNavigationHeight);
onDestroy(setNavigationHeight);
</script>
<div class="top-banner">
<svelte:window on:resize={setNavigationHeight} />
<div bind:this={container} class="top-banner alert is-action is-action-and-top-sticky">
<div class="top-banner-bg">
<div class="top-banner-bg-1">
<img
@@ -22,14 +52,28 @@
alt="" />
</div>
</div>
<div class="top-banner-content u-color-text-primary">
<slot />
</div>
<button
on:click|preventDefault={() => dispatch('close')}
type="button"
class="top-banner-button"
aria-label="close upgrade message">
<span class="icon-x" aria-hidden="true"></span>
</button>
<Button icon extraCompact class="top-banner-button position" on:click={() => dispatch('close')}>
<Icon icon={IconX} size="s" />
</Button>
</div>
<style>
.alert {
top: 0;
width: 100%;
z-index: 100;
position: fixed;
}
@media (max-width: 768px) {
:global(.top-banner-button.position) {
position: absolute;
padding-block-end: 3.7625rem;
}
}
</style>
-1
View File
@@ -6,5 +6,4 @@ export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
export { default as CreditsApplied } from './creditsApplied.svelte';
export { default as PlanSelection } from './planSelection.svelte';
export { default as EstimatedTotal } from './estimatedTotal.svelte';
export { default as SelectPlan } from './selectPlan.svelte';
+14 -4
View File
@@ -15,11 +15,12 @@
let name: string;
let error: string;
let modal: FakeModal;
async function handleSubmit() {
try {
const card = await submitStripeCard(name, page?.params?.organization ?? null);
show = false;
modal.closeModal();
invalidate(Dependencies.PAYMENT_METHODS);
dispatch('submit', card);
addNotification({
@@ -65,7 +66,12 @@
}
</script>
<FakeModal bind:show title="Add payment method" bind:error onSubmit={handleSubmit}>
<FakeModal
bind:this={modal}
bind:show
title="Add payment method"
bind:error
onSubmit={handleSubmit}>
<slot />
<InputText
id="name"
@@ -82,7 +88,10 @@
</div>
{/if}
<div class="stripe-element" bind:this={element}>
<div
style:display={isLoading ? 'none' : 'initial'}
class="stripe-element"
bind:this={element}>
<!-- Stripe will create form elements here -->
</div>
</div>
@@ -105,7 +114,8 @@
.loader-element {
width: 100%;
align-self: center;
justify-items: end;
justify-content: center;
display: flex;
}
}
</style>
@@ -21,6 +21,7 @@
async function cardSaved(event: CustomEvent<PaymentMethodData>) {
value = event.detail.$id;
invalidate(Dependencies.UPGRADE_PLAN);
invalidate(Dependencies.CREATE_ORGANIZATION);
}
onMount(() => {
+24 -41
View File
@@ -17,31 +17,6 @@
? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toString()
: org?.billingNextInvoiceDate;
const planData = [
{
id: 'members',
resource: 'Organization members',
unit: 'member'
},
{ id: 'bandwidth', resource: 'Bandwidth', unit: 'GB' },
{ id: 'storage', resource: 'Storage', unit: 'GB' },
{
id: 'executions',
resource: 'Function executions',
unit: ' executions'
},
{
id: 'users',
resource: 'Users',
unit: ' Users'
},
{
id: 'realtime',
resource: 'Concurrent connections',
unit: ' connections'
}
];
$: isFree = org.billingPlan === BillingPlan.FREE;
// equal or above means unlimited!
@@ -50,6 +25,10 @@
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
return exceedsSafeLimit ? 'Unlimited' : count || 0;
};
function getPlanLimit(key: string): number | false {
return plan[key] || false;
}
</script>
<Modal bind:show title="Usage rates">
@@ -77,29 +56,33 @@
<Table.Header.Cell column="limit" {root}>Limit</Table.Header.Cell>
<Table.Header.Cell column="rate" {root}>Rate</Table.Header.Cell>
</svelte:fragment>
{#each planData as usage}
{#if usage['id'] === 'members'}
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{usage.resource}</Table.Cell>
<Table.Cell column="limit" {root}>
{getCorrectSeatsCountValue(plan.addons.seats.limit)}
</Table.Cell>
{#each Object.values(plan.addons) as addon}
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{addon.invoiceDesc}</Table.Cell>
<Table.Cell column="limit" {root}>
{getCorrectSeatsCountValue(addon.limit)}
</Table.Cell>
{#if !isFree}
<Table.Cell column="rate" {root}>
{formatCurrency(plan.addons?.seats?.price)}/{usage?.unit}
{formatCurrency(addon.price)}/member
</Table.Cell>
</Table.Row.Base>
{:else}
{@const addon = plan.addons[usage.id]}
{/if}
</Table.Row.Base>
{/each}
{#each Object.entries(plan.usage) as [key, usage]}
{@const limit = getPlanLimit(key)}
{@const show = limit !== false}
{#if show}
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{usage.resource}</Table.Cell>
<Table.Cell column="resource" {root}>{usage.name}</Table.Cell>
<Table.Cell column="limit" {root}>
{abbreviateNumber(plan[usage.id])}{usage?.unit}
{abbreviateNumber(limit)}{usage.unit}
</Table.Cell>
{#if !isFree}
<Table.Cell column="rate" {root}>
{formatCurrency(addon?.price)}/{['MB', 'GB', 'TB'].includes(addon?.unit)
? addon?.value
: abbreviateNumber(addon?.value, 0)}{usage?.unit}
{formatCurrency(usage.price)}/{abbreviateNumber(
usage.value
)}{usage.unit}
</Table.Cell>
{/if}
</Table.Row.Base>
@@ -38,14 +38,6 @@
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;
}
+28 -27
View File
@@ -14,6 +14,7 @@
import { base } from '$app/paths';
import { newOrgModal } from '$lib/stores/organization';
import { Click, trackEvent } from '$lib/actions/analytics';
import { page } from '$app/stores';
type Project = {
name: string;
@@ -64,7 +65,7 @@
export let organizations: Organization[] = [];
$: selectedOrg = organizations.find((organization) => organization.isSelected);
$: selectedProject = selectedOrg?.projects.find((project) => project.isSelected);
$: selectedProject = $page.data.project;
let organisationBottomSheetOpen = false;
let projectsBottomSheetOpen = false;
@@ -81,7 +82,7 @@
title: 'Switch Organization',
items: organizations.map((organization) => ({
name: organization.name,
href: `/console/organization-${organization?.$id}`
href: `${base}/organization-${organization?.$id}`
}))
},
bottom: {
@@ -97,12 +98,12 @@
$: organizationsBottomSheet = !selectedOrg
? switchOrganization
: {
: ({
top: {
items: [
{
name: 'Organization overview',
href: `/console/organization-${selectedOrg?.$id}`
href: `${base}/organization-${selectedOrg?.$id}`
}
]
},
@@ -126,8 +127,8 @@
}
]
}
};
let projectsBottomSheet: SheetMenu;
} satisfies SheetMenu);
$: projectsBottomSheet = {
top:
selectedOrg?.projects.length > 1
@@ -140,12 +141,12 @@
if (index < 4) {
return {
name: project.name,
href: `/console/project-${project.region}-${project.$id}/overview`
href: `${base}/project-${project.region}-${project.$id}/overview`
};
} else if (index === 4) {
return {
name: 'All projects',
href: `/console/organization-${selectedOrg?.$id}`
href: `${base}/organization-${selectedOrg?.$id}`
};
}
return null;
@@ -157,7 +158,7 @@
{
name: 'Create project',
trailingIcon: IconPlus,
href: `/console/organization-${selectedOrg?.$id}?create-project`
href: `${base}/organization-${selectedOrg?.$id}?create-project`
}
]
},
@@ -168,12 +169,12 @@
{
name: 'Create project',
trailingIcon: IconPlus,
href: `/console/organization-${selectedOrg?.$id}?create-project`
href: `${base}/organization-${selectedOrg?.$id}?create-project`
}
]
}
: undefined
};
} satisfies SheetMenu;
function onResize() {
if ((organisationBottomSheetOpen || projectsBottomSheetOpen) && !$isSmallViewport) {
@@ -220,7 +221,7 @@
{#if selectedOrg}
<div use:melt={$itemOrganizations}>
<ActionMenu.Root>
<ActionMenu.Item.Anchor href={`/console/organization-${selectedOrg?.$id}`}
<ActionMenu.Item.Anchor href={`${base}/organization-${selectedOrg?.$id}`}
>Organization overview</ActionMenu.Item.Anchor
></ActionMenu.Root>
</div>
@@ -240,7 +241,7 @@
<div use:melt={$itemOrganizations}>
<ActionMenu.Root>
<ActionMenu.Item.Anchor
href={`/console/organization-${organization?.$id}`}
href={`${base}/organization-${organization?.$id}`}
>{organization.name}</ActionMenu.Item.Anchor>
</ActionMenu.Root>
</div>
@@ -271,7 +272,7 @@
<div use:melt={$itemOrganizations}>
<ActionMenu.Root>
<ActionMenu.Item.Anchor
href={`/console/organization-${organization?.$id}`}
href={`${base}/organization-${organization?.$id}`}
>{organization.name}</ActionMenu.Item.Anchor
></ActionMenu.Root>
</div>
@@ -317,17 +318,19 @@
<div use:melt={$itemProjects}>
<ActionMenu.Root>
<ActionMenu.Item.Anchor
href={`/console/project-${project.region}-${project.$id}`}
>{project.name}</ActionMenu.Item.Anchor
></ActionMenu.Root>
href={`${base}/project-${project.region}-${project.$id}`}>
{project.name}
</ActionMenu.Item.Anchor>
</ActionMenu.Root>
</div>
{:else if index === 4}
<div use:melt={$itemProjects}>
<ActionMenu.Root>
<ActionMenu.Item.Anchor
href={`/console/organization-${selectedOrg.$id}`}
>All projects</ActionMenu.Item.Anchor
></ActionMenu.Root>
href={`${base}/organization-${selectedOrg.$id}`}>
All projects
</ActionMenu.Item.Anchor>
</ActionMenu.Root>
</div>
{/if}
{/each}
@@ -337,18 +340,16 @@
<ActionMenu.Root>
<ActionMenu.Item.Anchor
leadingIcon={IconPlusSm}
href={`/console/organization-${selectedOrg?.$id}?create-project`}
>Create project</ActionMenu.Item.Anchor
></ActionMenu.Root>
href={`${base}/organization-${selectedOrg?.$id}?create-project`}>
Create project
</ActionMenu.Item.Anchor></ActionMenu.Root>
</div>
</Card.Base>
</div>
{/if}
</div>
<BottomSheet.Menu bind:isOpen={organisationBottomSheetOpen} menu={organizationsBottomSheet}
></BottomSheet.Menu>
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} menu={projectsBottomSheet}
></BottomSheet.Menu>
<BottomSheet.Menu bind:isOpen={organisationBottomSheetOpen} menu={organizationsBottomSheet} />
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} menu={projectsBottomSheet} />
<style lang="scss">
.menu {
+8 -3
View File
@@ -2,10 +2,11 @@
import { page } from '$app/state';
import { Empty } from '$lib/components';
import { CARD_LIMIT } from '$lib/constants';
import { getServiceLimit, type PlanServices } from '$lib/stores/billing';
import { preferences } from '$lib/stores/preferences';
import { isCloud } from '$lib/system';
import CardPlanLimit from './cardPlanLimit.svelte';
import { preferences } from '$lib/stores/preferences';
import { isSmallViewport } from '$lib/stores/viewport';
import { getServiceLimit, type PlanServices } from '$lib/stores/billing';
export let disableEmpty = true;
export let offset = 0;
@@ -17,9 +18,13 @@
$: planLimit = getServiceLimit(serviceId) || Infinity;
$: limit = preferences.get(page.route)?.limit ?? CARD_LIMIT;
$: gridItemStyle = $isSmallViewport
? undefined
: `--grid-item-size: ${total > 3 ? '22rem' : '25rem'}`;
</script>
<ul class="grid-box" style={`--grid-item-size:${total > 3 ? '22rem' : '25rem'};`} data-private>
<ul data-private class="grid-box" style={gridItemStyle}>
<slot />
{#if total > 3 ? total < limit + offset : total % 2 !== 0}
+3 -2
View File
@@ -19,8 +19,9 @@
</Layout.Stack>
</Table.Cell>
<Table.Cell column="name" {root}>{paymentMethod?.name}</Table.Cell>
<Table.Cell column="expiry" {root}
>{paymentMethod?.expiryMonth}/{paymentMethod?.expiryYear}</Table.Cell>
<Table.Cell column="expiry" {root}>
{paymentMethod?.expiryMonth}/{paymentMethod?.expiryYear}
</Table.Cell>
<Table.Cell column="status" {root}>
{#if paymentMethod?.lastError || paymentMethod?.expired}
<Popover let:toggle>
+6 -12
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { Link } from '$lib/elements';
import { consoleVariables } from '$routes/(console)/store';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import {
Badge,
@@ -10,9 +9,10 @@
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
export let domain: string;
export let verified = false;
export let verified = undefined;
let subdomain = domain.split('.').slice(0, -2).join('.');
</script>
@@ -23,16 +23,10 @@
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary">
{domain}
</Typography.Text>
{#if verified}
<Badge variant="secondary" type="success" content="Verified" />
{#if verified === true}
<Badge variant="secondary" type="success" size="xs" content="Verified" />
{:else if verified === false}
<Badge variant="secondary" type="error" content="Verification failed" />
{:else}
<Badge
variant="secondary"
type="warning"
size="xs"
content="Pending verification" />
<Badge variant="secondary" type="error" size="xs" content="Verification failed" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
@@ -54,7 +48,7 @@
<InteractiveText
variant="copy"
isVisible
text={$consoleVariables._APP_DOMAIN_TARGET_CNAME} />
text={$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME} />
</Table.Cell>
</Table.Row.Base>
</Table.Root>
@@ -1,13 +1,13 @@
<script lang="ts">
import { consoleVariables } from '$routes/(console)/store';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
import { Badge, Layout, Typography, Table, InteractiveText } from '@appwrite.io/pink-svelte';
export let domain: string;
export let verified = false;
export let verified = undefined;
const nameserverList = $consoleVariables?._APP_DOMAINS_NAMESERVERS
? $consoleVariables?._APP_DOMAINS_NAMESERVERS?.split(',')
const nameserverList = $regionalConsoleVariables?._APP_DOMAINS_NAMESERVERS
? $regionalConsoleVariables?._APP_DOMAINS_NAMESERVERS?.split(',')
: ['ns1.appwrite.io', 'ns2.appwrite.io'];
</script>
@@ -16,10 +16,10 @@
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary">
{domain}
</Typography.Text>
{#if verified}
{#if verified === true}
<Badge variant="secondary" type="success" size="xs" content="Verified" />
{:else if verified === false}
<Badge variant="secondary" type="warning" size="xs" content="Pending verification" />
<Badge variant="secondary" type="warning" size="xs" content="Verification failed" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
+7 -13
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { Link } from '$lib/elements';
import { consoleVariables } from '$routes/(console)/store';
import {
Badge,
Layout,
@@ -9,9 +8,10 @@
InteractiveText,
Alert
} from '@appwrite.io/pink-svelte';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
export let domain: string;
export let verified = false;
export let verified = undefined;
export let variant: 'cname' | 'a' | 'aaaa';
export let service: 'sites' | 'general' = 'general';
@@ -21,12 +21,12 @@
switch (variant) {
case 'cname':
return service === 'general'
? $consoleVariables._APP_DOMAIN_TARGET_CNAME
: $consoleVariables._APP_DOMAIN_SITES;
? $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME
: $regionalConsoleVariables._APP_DOMAIN_SITES;
case 'a':
return $consoleVariables._APP_DOMAIN_TARGET_A;
return $regionalConsoleVariables._APP_DOMAIN_TARGET_A;
case 'aaaa':
return $consoleVariables._APP_DOMAIN_TARGET_AAAA;
return $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA;
}
}
</script>
@@ -37,16 +37,10 @@
<Typography.Text variant="l-500" color="--fgcolor-neutral-primary">
{domain}
</Typography.Text>
{#if verified}
{#if verified === true}
<Badge variant="secondary" type="success" size="xs" content="Verified" />
{:else if verified === false}
<Badge variant="secondary" type="error" size="xs" content="Verification failed" />
{:else}
<Badge
variant="secondary"
type="warning"
size="xs"
content="Pending verification" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
+5 -1
View File
@@ -97,7 +97,11 @@
<Layout.Stack gap="s" alignContent="flex-start">
<!-- `Raw time` as per design -->
<Typography.Caption color="--fgcolor-neutral-tertiary" variant="400">
{timeToString}
{#if $$slots.title}
<slot name="title" />
{:else}
{timeToString}
{/if}
</Typography.Caption>
<!-- `Absolute time` as per design -->
+6 -1
View File
@@ -129,5 +129,10 @@
bind:value={expirationSelect} />
{#if expirationSelect === 'custom'}
<InputDateTime required id="expire" label={dateSelectorLabel} bind:value={expirationCustom} />
<InputDateTime
required
type="date"
id="expire"
label={dateSelectorLabel}
bind:value={expirationCustom} />
{/if}
+1 -1
View File
@@ -30,7 +30,7 @@
}
}
function closeModal() {
export function closeModal() {
document.documentElement.classList.remove('u-overflow-hidden');
show = false;
}
@@ -65,8 +65,6 @@
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
};
@@ -1,9 +1,9 @@
<script lang="ts">
import { LabelCard } from '$lib/components';
import { consoleVariables } from '$routes/(console)/store';
import { Layout } from '@appwrite.io/pink-svelte';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
const isVcsEnabled = $consoleVariables?._APP_VCS_ENABLED === true;
const isVcsEnabled = $regionalConsoleVariables?._APP_VCS_ENABLED === true;
export let connectBehaviour: 'now' | 'later' = isVcsEnabled ? 'now' : 'later';
</script>
+5 -5
View File
@@ -1,14 +1,14 @@
<script lang="ts">
import Button from '$lib/elements/forms/button.svelte';
import { consoleVariables } from '$routes/(console)/store';
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
import { Alert, Card, Empty, Icon, Layout } from '@appwrite.io/pink-svelte';
import { isSelfHosted } from '$lib/system';
import { connectGitHub } from '$lib/stores/git';
import Button from '$lib/elements/forms/button.svelte';
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
import { Alert, Card, Empty, Icon, Layout } from '@appwrite.io/pink-svelte';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
export let callbackState: Record<string, string> = null;
let isVcsEnabled = $consoleVariables?._APP_VCS_ENABLED === true;
let isVcsEnabled = $regionalConsoleVariables?._APP_VCS_ENABLED === true;
</script>
<Layout.Stack>
@@ -2,10 +2,10 @@
import { Trim } from '$lib/components';
import { Link } from '$lib/elements';
import { Button } from '$lib/elements/forms';
import { protocol } from '$routes/(console)/store';
import type { Models } from '@appwrite.io/console';
import { IconExternalLink, IconQrcode } from '@appwrite.io/pink-icons-svelte';
import { ActionMenu, Icon, Layout, Popover, Tag, Typography } from '@appwrite.io/pink-svelte';
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
let {
domains,
@@ -31,7 +31,7 @@
<Layout.Stack gap="xxs" direction="row" alignItems="center">
{#if domains?.total}
<Link external href={`${$protocol}${sortedDomains[0]?.domain}`} variant="muted">
<Link external href={`${$regionalProtocol}${sortedDomains[0]?.domain}`} variant="muted">
<Layout.Stack gap="xxs" direction="row" alignItems="center">
<Trim alternativeTrim>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
@@ -51,7 +51,7 @@
{#each sortedDomains as rule, i}
{#if i !== 0}
<ActionMenu.Item.Anchor
href={`${$protocol}${rule.domain}`}
href={`${$regionalProtocol}${rule.domain}`}
external
leadingIcon={IconExternalLink}>
<Trim alternativeTrim>
@@ -65,7 +65,6 @@
id="branch"
label="Production branch"
placeholder="Select branch"
isSearchable
bind:value={branch}
on:select={(event) => {
branch = event.detail.value;
+37 -28
View File
@@ -5,6 +5,7 @@
import { sdk } from '$lib/stores/sdk';
import { repositories } from '$routes/(console)/project-[region]-[project]/functions/function-[function]/store';
import { installation, installations, repository } from '$lib/stores/vcs';
import { isSmallViewport } from '$lib/stores/viewport';
import {
Layout,
Table,
@@ -49,7 +50,9 @@
async function loadInstallations() {
if (installationList) {
if (installationList.installations.length) {
untrack(() => (selectedInstallation = installationList.installations[0].$id));
if (!selectedInstallation) {
untrack(() => (selectedInstallation = installationList.installations[0].$id));
}
installation.set(
installationList.installations.find(
(entry) => entry.$id === selectedInstallation
@@ -62,7 +65,9 @@
.forProject(page.params.region, page.params.project)
.vcs.listInstallations();
if (installations.length) {
untrack(() => (selectedInstallation = installations[0].$id));
if (!selectedInstallation) {
untrack(() => (selectedInstallation = installationList.installations[0].$id));
}
installation.set(installations.find((entry) => entry.$id === selectedInstallation));
}
return installations;
@@ -133,7 +138,7 @@
<InputSearch placeholder="Search repositories" disabled />
</Layout.Stack>
{:then installations}
<Layout.Stack direction="row">
<Layout.Stack direction={$isSmallViewport ? 'column' : 'row'}>
<InputSelect
id="installation"
options={[
@@ -233,39 +238,43 @@
<Layout.Stack
gap="s"
direction="row"
alignItems="center">
<Typography.Text
truncate
color="--fgcolor-neutral-secondary">
{repo.name}
</Typography.Text>
{#if repo.private}
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
{/if}
<time datetime={repo.pushedAt}>
<Typography.Caption
variant="400"
truncate
color="--fgcolor-neutral-tertiary">
{timeFromNow(repo.pushedAt)}
</Typography.Caption>
</time>
</Layout.Stack>
{#if action === 'button'}
alignItems="center"
justifyContent="space-between">
<Layout.Stack
direction="row"
justifyContent="flex-end">
gap="s"
alignItems="center">
<Typography.Text
truncate
color="--fgcolor-neutral-secondary">
{repo.name}
</Typography.Text>
{#if repo.private}
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
{/if}
{#if !$isSmallViewport}
<time datetime={repo.pushedAt}>
<Typography.Caption
variant="400"
truncate
color="--fgcolor-neutral-tertiary">
{timeFromNow(repo.pushedAt)}
</Typography.Caption>
</time>
{/if}
</Layout.Stack>
{#if action === 'button'}
<PinkButton.Button
size="xs"
variant="secondary"
on:click={() => connect(repo)}>
Connect
</PinkButton.Button>
</Layout.Stack>
{/if}
{/if}
</Layout.Stack>
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
@@ -3,7 +3,7 @@
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge == null) {
if (challenge === null) {
challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
@@ -59,9 +59,9 @@
onMount(async () => {
const enabledNonRecoveryFactors = enabledFactors.filter(
([factor, _]) => factor != 'recoveryCode'
([factor, _]) => factor !== 'recoveryCode'
);
if (enabledNonRecoveryFactors.length == 1) {
if (enabledNonRecoveryFactors.length === 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
@@ -77,7 +77,7 @@
</Typography.Text>
<InputText id="recovery-code" bind:value={code} required autofocus />
{:else}
{#if factors.totp && (challengeType == AuthenticationFactor.Totp || challengeType == null)}
{#if factors.totp && (challengeType === AuthenticationFactor.Totp || challengeType === null)}
<Typography.Text align="center"
>Enter below a 6-digit one-time code generated by your authentication app.</Typography.Text>
{:else if challengeType === AuthenticationFactor.Email}
@@ -95,7 +95,7 @@
{#if enabledFactors.length > 1}
<span class="with-separators eyebrow-heading-3">or</span>
<div class="u-flex-vertical u-gap-8">
{#if factors.totp && challengeType != null && challengeType != AuthenticationFactor.Totp}
{#if factors.totp && challengeType !== null && challengeType !== AuthenticationFactor.Totp}
<Button
secondary
fullWidth
+6 -2
View File
@@ -55,6 +55,7 @@
import { user } from '$lib/stores/user';
import { Click, trackEvent } from '$lib/actions/analytics';
import type { HTMLAttributes } from 'svelte/elements';
import { beforeNavigate } from '$app/navigation';
let showSupport = false;
@@ -115,9 +116,10 @@
$: currentOrg = organizations.find((org) => org.isSelected);
$: selectedProject = currentOrg?.projects.find((project) => project.isSelected);
beforeNavigate(() => (showAccountMenu = false));
</script>
<Navbar.Base {...$$props}>
<Navbar.Base>
<div slot="left" class="left">
<div class="only-mobile-tablet">
<button
@@ -128,7 +130,9 @@
<Icon icon={IconMenuAlt4} />
</button>
</div>
<a href={base} class="only-desktop">
<a
href={currentOrg?.$id ? `${base}/organization-${currentOrg?.$id}` : base}
class="only-desktop">
<img src={logo.src} alt={logo.alt} />
</a>
<Breadcrumbs {organizations} />
+5 -3
View File
@@ -35,6 +35,8 @@
}
}
const none = undefined as never;
// function pagination(page: number, total: number) {
// const pagesShown = 5;
// const start = Math.max(
@@ -56,11 +58,11 @@
{#if !hidePages}
<Pagination
{limit}
page={currentPage}
{total}
type="button"
on:page={handleOptionClick}
createLink={undefined as never} />
createLink={none}
page={currentPage}
on:page={handleOptionClick} />
{:else}
<Layout.Stack direction="row" inline>
<Button.Button
+25 -9
View File
@@ -12,6 +12,7 @@
Layout,
Link,
Selector,
Spinner,
Table,
Typography
} from '@appwrite.io/pink-svelte';
@@ -24,9 +25,10 @@
let search = '';
let offset = 0;
let results: Models.TeamList<Record<string, unknown>>;
let selected: Set<string> = new Set();
let isLoading = false;
let hasSelection = false;
let selected: Set<string> = new Set();
let results: Models.TeamList<Record<string, unknown>>;
function reset() {
offset = 0;
@@ -41,9 +43,11 @@
async function request() {
if (!show) return;
isLoading = true;
results = await sdk
.forProject(page.params.region, page.params.project)
.teams.list([Query.limit(5), Query.offset(offset)], search || undefined);
isLoading = false;
}
function onSelection(role: string) {
@@ -71,23 +75,24 @@
</script>
<Modal title="Select teams" bind:show onSubmit={create} on:close={reset}>
<Typography.Text
<Typography.Text slot="description"
>Grant access to any member of a specific team. To grant access to team members with
specific roles, you will need to set a <Link.Button on:click={() => dispatch('custom')}
>custom permission</Link.Button
>.</Typography.Text>
<InputSearch autofocus placeholder="Search by name or ID" bind:value={search} />
{#if results?.teams?.length}
<Table.Root columns={[{ id: 'checkbox', width: 40 }, { id: 'team' }]} let:root>
<Table.Root columns={[{ id: 'checkbox', width: 20 }, { id: 'team' }]} let:root>
{#each results.teams as team (team.$id)}
{@const role = `team:${team.$id}`}
{@const exists = $groups.has(role)}
<Table.Row.Button {root} on:click={() => onSelection(role)} disabled={exists}>
<Table.Cell column="checkbox" {root}>
<Selector.Checkbox
size="s"
id={team.$id}
checked={exists || selected.has(role)}
disabled={exists} />
disabled={exists}
checked={exists || selected.has(role)} />
</Table.Cell>
<Table.Cell column="team" {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
@@ -105,10 +110,16 @@
</Table.Row.Button>
{/each}
</Table.Root>
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<p class="text">Total results: {results?.total}</p>
<PaginationInline limit={5} bind:offset total={results?.total} hidePages />
</div>
<PaginationInline
limit={5}
bind:offset
total={results?.total}
hidePages
on:change={request} />
</Layout.Stack>
{:else if search}
<EmptySearch bind:search target="teams" hidePages>
<Button
@@ -119,6 +130,11 @@
size="s">Documentation</Button>
<Button secondary on:click={() => (search = '')}>Clear search</Button>
</EmptySearch>
{:else if isLoading}
<!-- 275px nearly matches the height of at-least 5 items in the table above -->
<div style:margin-inline="auto" style:min-height="275px" style:align-content="center">
<Spinner size="m" />
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no teams. Create a team to see them here." type="secondary">
+23 -6
View File
@@ -14,6 +14,7 @@
Layout,
Link,
Selector,
Spinner,
Table,
Typography
} from '@appwrite.io/pink-svelte';
@@ -27,9 +28,10 @@
let search = '';
let offset = 0;
let results: Models.UserList<Record<string, unknown>>;
let selected: Set<string> = new Set();
let isLoading = false;
let hasSelection = false;
let selected: Set<string> = new Set();
let results: Models.UserList<Record<string, unknown>>;
function reset() {
offset = 0;
@@ -44,9 +46,11 @@
async function request() {
if (!show) return;
isLoading = true;
results = await sdk
.forProject(page.params.region, page.params.project)
.users.list([Query.limit(5), Query.offset(offset)], search || undefined);
isLoading = false;
}
function onSelection(role: string) {
@@ -74,8 +78,11 @@
</script>
<Modal title="Select users" bind:show onSubmit={create} on:close={reset}>
<Typography.Text>Grant access to any authenticated or anonymous user.</Typography.Text>
<Typography.Text slot="description"
>Grant access to any authenticated or anonymous user.</Typography.Text>
<InputSearch autofocus placeholder="Search by name, email, phone or ID" bind:value={search} />
{#if results?.users?.length}
<Table.Root columns={[{ id: 'checkbox', width: 40 }, { id: 'user' }]} let:root>
{#each results.users as user (user.$id)}
@@ -86,8 +93,8 @@
<Selector.Checkbox
size="s"
id={user.$id}
checked={exists || selected.has(role)}
disabled={exists} />
disabled={exists}
checked={exists || selected.has(role)} />
</Table.Cell>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
@@ -141,7 +148,12 @@
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<p class="text">Total results: {results?.total}</p>
<PaginationInline limit={5} bind:offset total={results?.total} hidePages />
<PaginationInline
limit={5}
bind:offset
total={results?.total}
hidePages
on:change={request} />
</Layout.Stack>
{:else if search}
<EmptySearch bind:search target="users" hidePages>
@@ -153,6 +165,11 @@
size="s">Documentation</Button>
<Button secondary on:click={() => (search = '')}>Clear search</Button>
</EmptySearch>
{:else if isLoading}
<!-- 275px nearly matches the height of at-least 5 items in the table above -->
<div style:margin-inline="auto" style:min-height="275px" style:align-content="center">
<Spinner size="m" />
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no users. Create a user to see them here." type="secondary">
+3 -3
View File
@@ -39,11 +39,11 @@
import { Click, trackEvent } from '$lib/actions/analytics';
import type { HTMLAttributes } from 'svelte/elements';
import type { NavbarProject } from '$lib/components/navbar.svelte';
import type { Models } from '@appwrite.io/console';
type $$Props = HTMLAttributes<HTMLElement> & {
state?: 'closed' | 'open' | 'icons';
project: NavbarProject | undefined;
project: Models.Project | undefined;
avatar: string;
progressCard?: {
title: string;
@@ -84,7 +84,7 @@
</script>
<div
class:only-mobile-tablet={project === undefined}
class:only-mobile-tablet={!project}
style:--overlay-on-neutral={$app.themeInUse === 'dark'
? 'var(--neutral-750)'
: 'var(--neutral-100)'}>
+3 -2
View File
@@ -13,8 +13,9 @@
</Typography.Caption>
<slot>
{#if value !== null && value !== undefined}
<Typography.Text size="s" truncate color="--fgcolor-neutral-primary"
>{value}</Typography.Text>
<Typography.Text size="s" truncate color="--fgcolor-neutral-primary">
{value}
</Typography.Text>
{:else}
<Skeleton variant="line" width="100%" height={19.5} />
{/if}
+1
View File
@@ -18,6 +18,7 @@ export enum Dependencies {
INVOICES = 'dependency:invoices',
ADDRESS = 'dependency:address',
UPGRADE_PLAN = 'dependency:upgrade_plan',
CREATE_ORGANIZATION = 'dependency:create_organization',
PAYMENT_METHODS = 'dependency:paymentMethods',
ORGANIZATION = 'dependency:organization',
MEMBERS = 'dependency:members',
+15
View File
@@ -124,3 +124,18 @@
<slot slot="end" name="end" />
</Button.Button>
{/if}
<!-- built-in classes don't seem to work -->
<style lang="scss">
:global {
.is-full-width {
width: 100% !important;
}
@media (max-width: 768px) {
.is-full-width-mobile {
width: 100% !important;
}
}
}
</style>
+7 -1
View File
@@ -12,6 +12,7 @@
export let autofocus = false;
export let autocomplete = false;
export let step: number | 'any' = 0.001;
export let type: 'date' | 'time' | 'datetime-local' = 'date';
let error: string;
let element: HTMLInputElement;
@@ -36,6 +37,10 @@
$: if (value) {
error = null;
}
function onChange(event: CustomEvent) {
value = (event.target as HTMLInputElement).value;
}
</script>
<Layout.Stack gap="s" direction="row">
@@ -47,8 +52,9 @@
{required}
{value}
{step}
{type}
helper={error}
on:change={(event) => (value = (event.target as HTMLInputElement).value)}
on:change={onChange}
autocomplete={autocomplete ? 'on' : 'off'}>
{#if nullable}
<Selector.Checkbox
@@ -17,7 +17,6 @@
leadingIcon?: ComponentType;
leadingHtml?: string;
}[];
export let isSearchable = false;
let element: HTMLSelectElement;
let error: string;
@@ -54,7 +53,6 @@
{optionalText}
{placeholder}
{disabled}
{isSearchable}
helper={error ?? helper}
{required}
state={error ? 'error' : 'default'}
+24
View File
@@ -0,0 +1,24 @@
import { env } from '$env/dynamic/public';
import type { Account } from './stores/user';
import type { Organization } from './stores/organization';
// Parse feature flags from env as a string array (exact match only)
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
function isFlagEnabled(name: string) {
// loose generic to allow safe access while retaining type safety
return <T extends { account?: Account; organization?: Organization }>(data: T) => {
const { account, organization } = data;
return !!(
flagsRaw.includes(name) ||
account?.prefs?.[`flags-${name}`] ||
organization?.prefs?.[`flags-${name}`]
);
};
}
export const flags = {
showSites: isFlagEnabled('sites'),
showCsvImport: isFlagEnabled('csv-import')
};
+114 -15
View File
@@ -1,7 +1,7 @@
import type { DnsRecord } from '$lib/sdk/domains';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
export async function createRecord(record: Partial<DnsRecord>, domainId: string) {
export async function createRecord(record: Partial<Models.DnsRecord>, domainId: string) {
switch (record.type) {
case 'A':
return await sdk.forConsole.domains.createRecordA(
@@ -40,8 +40,8 @@ export async function createRecord(record: Partial<DnsRecord>, domainId: string)
return await sdk.forConsole.domains.createRecordTXT(
domainId,
record.name,
record.value,
record.ttl,
record.value,
record?.comment || undefined
);
case 'NS':
@@ -90,17 +90,116 @@ export async function createRecord(record: Partial<DnsRecord>, domainId: string)
}
}
export async function updateRecord(record: Partial<Models.DnsRecord>, domainId: string) {
switch (record.type) {
case 'A':
return await sdk.forConsole.domains.updateRecordA(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'AAAA':
return await sdk.forConsole.domains.updateRecordAAAA(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'CNAME':
return await sdk.forConsole.domains.updateRecordCNAME(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'MX':
return await sdk.forConsole.domains.updateRecordMX(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.priority,
record.comment
);
case 'TXT':
return await sdk.forConsole.domains.updateRecordTXT(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'NS':
return await sdk.forConsole.domains.updateRecordNS(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'SRV':
return await sdk.forConsole.domains.updateRecordSRV(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.priority,
record.weight,
record.port,
record?.comment || undefined
);
case 'CAA':
return await sdk.forConsole.domains.updateRecordCAA(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'HTTPS':
return await sdk.forConsole.domains.updateRecordHTTPS(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
case 'ALIAS':
return await sdk.forConsole.domains.updateRecordAlias(
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
}
}
export type ParsedRecords = {
A: Partial<DnsRecord>[];
AAAA: Partial<DnsRecord>[];
CNAME: Partial<DnsRecord>[];
MX: Partial<DnsRecord>[];
TXT: Partial<DnsRecord>[];
NS: Partial<DnsRecord>[];
SRV: Partial<DnsRecord>[];
CAA: Partial<DnsRecord>[];
PTR: Partial<DnsRecord>[];
HTTPS: Partial<DnsRecord>[];
ALIAS: Partial<DnsRecord>[];
[key: string]: Partial<DnsRecord>[];
A: Partial<Models.DnsRecord>[];
AAAA: Partial<Models.DnsRecord>[];
CNAME: Partial<Models.DnsRecord>[];
MX: Partial<Models.DnsRecord>[];
TXT: Partial<Models.DnsRecord>[];
NS: Partial<Models.DnsRecord>[];
SRV: Partial<Models.DnsRecord>[];
CAA: Partial<Models.DnsRecord>[];
PTR: Partial<Models.DnsRecord>[];
HTTPS: Partial<Models.DnsRecord>[];
ALIAS: Partial<Models.DnsRecord>[];
[key: string]: Partial<Models.DnsRecord>[];
};
+27
View File
@@ -0,0 +1,27 @@
import { getFlagUrl } from '$lib/helpers/flag';
import type { Models } from '@appwrite.io/console';
type RegionOption = {
label: string;
value: string;
disabled: boolean;
leadingHtml: string;
};
export function filterRegions(regions: Models.ConsoleRegion[]): RegionOption[] {
if (!regions.length) return [];
return regions
.filter((region) => region.$id !== 'default')
.sort((a, b) => {
if (a.disabled && !b.disabled) return 1;
if (!a.disabled && b.disabled) return -1;
return a.name.localeCompare(b.name);
})
.map((region) => ({
label: region.name,
value: region.$id,
disabled: region.disabled || !region.available,
leadingHtml: `<img src='${getFlagUrl(region.flag)}' alt='Region flag'/>`
}));
}
-1
View File
@@ -15,7 +15,6 @@ export function isASubdomain(domain: string | null): boolean {
const { domain: apex, subdomain } = parse(domain);
if (!apex) return false;
if (subdomain === 'www') return false;
return !!subdomain;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
<div class="top-cover-console" style:block-size={blocksize}>
<div class="cover-container" {style}>
<Layout.Stack direction="row" alignItems="center">
<Layout.Stack direction="row" alignItems="baseline">
<slot name="header" />
</Layout.Stack>
<slot />
+18 -12
View File
@@ -1,18 +1,24 @@
<script lang="ts">
import { isSmallViewport } from '$lib/stores/viewport';
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import { IconChevronLeft } from '@appwrite.io/pink-icons-svelte';
import { Typography, Button, Icon } from '@appwrite.io/pink-svelte';
import { Typography, Button, Icon, Layout } from '@appwrite.io/pink-svelte';
export let href: string = null;
</script>
{#if href}
<span style:position="absolute" style:left="-2.75rem">
<Button.Anchor size="s" icon variant="text" {href} aria-label="page back">
<Icon icon={IconChevronLeft} />
</Button.Anchor>
</span>
{/if}
<Typography.Title truncate color="--fgcolor-neutral-primary" size={$isSmallViewport ? 'm' : 'l'}>
<slot />
</Typography.Title>
<Layout.Stack justifyContent="center" alignItems="center" direction="row" gap="xs" inline>
{#if href}
<span
style={$isTabletViewport ? 'position: relative' : 'position: absolute; left: -2.75rem'}>
<Button.Anchor size="s" icon variant="text" {href} aria-label="page back">
<Icon icon={IconChevronLeft} />
</Button.Anchor>
</span>
{/if}
<Typography.Title
truncate
color="--fgcolor-neutral-primary"
size={$isSmallViewport ? 'm' : 'l'}>
<slot />
</Typography.Title>
</Layout.Stack>
+4 -20
View File
@@ -9,6 +9,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import type { Models } from '@appwrite.io/console';
import { filterRegions } from '$lib/helpers/regions';
export let projectName: string;
export let id: string;
@@ -18,25 +19,6 @@
export let projects: number = undefined;
let showCustomId = false;
function getRegions() {
return regions
.filter((region) => region.$id !== 'default')
.sort((regionA, regionB) => {
if (regionA.disabled && !regionB.disabled) {
return 1;
}
return regionA.name > regionB.name ? 1 : -1;
})
.map((region) => {
return {
label: region.name,
value: region.$id,
leadingHtml: `<img src='${getFlagUrl(region.flag)}' alt='Region flag'/>`,
disabled: region.disabled
};
});
}
</script>
<svelte:head>
@@ -44,6 +26,7 @@
<link rel="preload" as="image" href={getFlagUrl(region.flag)} />
{/each}
</svelte:head>
<form on:submit|preventDefault>
<Layout.Stack direction="column" gap="xxl">
{#if showTitle}
@@ -79,9 +62,10 @@
{#if isCloud && regions.length > 0}
<Layout.Stack gap="xs">
<Input.Select
required
bind:value={region}
placeholder="Select a region"
options={getRegions()}
options={filterRegions(regions)}
label="Region" />
<Typography.Text>Region cannot be changed after creation</Typography.Text>
</Layout.Stack>
+17 -17
View File
@@ -2,7 +2,7 @@
import { beforeNavigate } from '$app/navigation';
import { Navbar, Sidebar } from '$lib/components';
import type { NavbarProject } from '$lib/components/navbar.svelte';
import { wizard } from '$lib/stores/wizard';
import { isNewWizardStatusOpen, wizard } from '$lib/stores/wizard';
import { activeHeaderAlert } from '$routes/(console)/store';
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
@@ -11,21 +11,20 @@
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { tierToPlan } from '$lib/stores/billing';
import { type Models } from '@appwrite.io/console';
import { isCloud } from '$lib/system';
import SideNavigation from '$lib/layout/navigation.svelte';
import { hasOnboardingDismissed } from '$lib/helpers/onboarding';
import { isSidebarOpen } from '$lib/stores/sidebar';
import { BillingPlan } from '$lib/constants';
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
export let showSideNavigation = false;
export let showHeader = true;
export let showFooter = true;
export let loadedProjects: Array<NavbarProject> = [];
export let projects: Array<Models.Project> = [];
export let selectedProject: Models.Project | null = null;
$: selectedProject = loadedProjects.find((project) => project.isSelected);
let yOnMenuOpen: number;
let showContentTransition = false;
let timeoutId: ReturnType<typeof setTimeout>;
@@ -113,6 +112,9 @@
let state: undefined | 'open' | 'closed' | 'icons' = 'closed';
$: state = $isSidebarOpen ? 'open' : 'closed';
let isProjectPage;
$: isProjectPage = $page.route?.id?.includes('project-');
function handleResize() {
$isSidebarOpen = false;
showAccountMenu = false;
@@ -120,11 +122,9 @@
const progressCard = function getProgressCard() {
if (selectedProject && !hasOnboardingDismissed(selectedProject.$id)) {
const currentProject = projects.find((project) => project.$id === selectedProject.$id);
return {
title: 'Get started',
percentage: currentProject && currentProject.platforms.length ? 100 : 33
percentage: selectedProject && selectedProject.platforms.length ? 100 : 33
};
}
@@ -134,7 +134,7 @@
<svelte:window on:resize={handleResize} />
<svelte:body use:style={$bodyStyle} />
{#if $activeHeaderAlert?.show}
{#if $activeHeaderAlert?.show && !$isNewWizardStatusOpen}
<svelte:component this={$activeHeaderAlert.component} />
{/if}
<main
@@ -159,7 +159,7 @@
<div
class="content"
class:has-transition={showContentTransition}
class:icons-content={state === 'icons'}
class:icons-content={state === 'icons' && isProjectPage}
class:no-sidebar={!showSideNavigation}>
<section class="main-content" data-test={showSideNavigation}>
{#if $page.data?.header}
@@ -172,14 +172,14 @@
</section>
</div>
<button
type="button"
class="overlay-button"
aria-label="Close sidebar"
class:overlay={$isSidebarOpen}
on:click={() => {
$isSidebarOpen = false;
}}></button>
{#if $isSidebarOpen}
<button
type="button"
class="overlay-button"
aria-label="Close sidebar"
class:overlay={$isSidebarOpen}
on:click={() => ($isSidebarOpen = false)}></button>
{/if}
</main>
<style lang="scss">
+7 -2
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onDestroy, onMount } from 'svelte';
import { Layout } from '@appwrite.io/pink-svelte';
import { trackEvent } from '$lib/actions/analytics';
import WizardExitModal from './wizardExitModal.svelte';
import { goto } from '$app/navigation';
import { wizard } from '$lib/stores/wizard';
import { isNewWizardStatusOpen, wizard } from '$lib/stores/wizard';
type $$Props =
| {
@@ -56,6 +57,10 @@
}
}
}
onMount(() => ($isNewWizardStatusOpen = true));
onDestroy(() => ($isNewWizardStatusOpen = false));
</script>
<svelte:window on:keydown={handleKeydown} />
-50
View File
@@ -1,50 +0,0 @@
<script lang="ts">
import { WizardWithSteps } from '$lib/layout';
import { invalidate } from '$app/navigation';
import { wizard } from '$lib/stores/wizard';
import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
import { dependencyStore, domain } from './wizard/store';
import Step1 from './wizard/step1.svelte';
import Step2 from './wizard/step2.svelte';
import { onMount } from 'svelte';
import type { Models } from '@appwrite.io/console';
import { page } from '$app/state';
import { realtime } from '$lib/stores/sdk';
onMount(() => {
domain.set({ $id: '', domain: '' });
return realtime
.forProject(page.params.region, page.params.project)
.subscribe<Models.ProxyRule>('console', (message) => {
if (
message.channels.includes(`projects.${page.params.project}`) &&
message.events.includes('rules.*')
) {
domain.set(message.payload);
}
});
});
async function onFinish() {
await invalidate($dependencyStore);
wizard.hide();
}
const stepsComponents: WizardStepsType = new Map();
stepsComponents.set(1, {
label: 'Add domain',
component: Step1
});
stepsComponents.set(2, {
label: 'Configuration',
component: Step2
});
</script>
<WizardWithSteps
title="Create domain"
steps={stepsComponents}
finalAction="Go to console"
on:exit={onFinish}
on:finish={onFinish} />
-51
View File
@@ -1,51 +0,0 @@
<script lang="ts">
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import type { Models } from '@appwrite.io/console';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import type { Dependencies } from '$lib/constants';
import Confirm from '$lib/components/confirm.svelte';
import { page } from '$app/state';
export let showDelete = false;
export let selectedDomain: Models.ProxyRule;
export let dependency: Dependencies;
let error: string;
function getCorrectMessage(): string {
const resourceType = selectedDomain.type;
switch (resourceType) {
case 'api':
return `access ${resourceType}`;
case 'deployment':
return `view your ${resourceType}`;
case 'redirect':
return `view your ${resourceType} `;
}
}
async function deleteDomain() {
try {
await sdk
.forProject(page.params.region, page.params.project)
.proxy.deleteRule(selectedDomain.$id);
await invalidate(dependency);
showDelete = false;
addNotification({
type: 'success',
message: `${selectedDomain.domain} has been deleted`
});
trackEvent(Submit.DomainDelete);
} catch (e) {
error = e.message;
trackError(e, Submit.DomainDelete);
}
}
</script>
<Confirm onSubmit={deleteDomain} title="Delete domain" bind:open={showDelete} bind:error>
<p data-private>Are you sure you want to delete <b>{selectedDomain?.domain}</b>?</p>
<p>You will no longer be able to {getCorrectMessage()} by visiting this domain.</p>
</Confirm>
-186
View File
@@ -1,186 +0,0 @@
<script lang="ts">
import { Empty, Modal, SearchQuery } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { onMount } from 'svelte';
import { dependencyStore } from './wizard/store';
import { toLocaleDate } from '$lib/helpers/date';
import { wizard } from '$lib/stores/wizard';
import type { Dependencies } from '$lib/constants';
import type { Models } from '@appwrite.io/console';
import Create from './create.svelte';
import Delete from './delete.svelte';
import Retry from './wizard/retry.svelte';
import { canWriteRules } from '$lib/stores/roles';
import {
Layout,
Table,
Link,
Icon,
Badge,
Popover,
ActionMenu
} from '@appwrite.io/pink-svelte';
import {
IconCheckCircle,
IconDotsHorizontal,
IconExclamationCircle,
IconPlus,
IconRefresh,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
import { base } from '$app/paths';
export let rules: Models.ProxyRuleList;
export let dependency: Dependencies;
let showDelete = false;
let showRetry = false;
let selectedDomain: Models.ProxyRule;
let retryError = null;
onMount(() => {
dependencyStore.set(dependency);
});
function openWizard() {
wizard.start(Create);
}
function openRetry(rule: Models.ProxyRule) {
retryError = null;
selectedDomain = rule;
showRetry = true;
}
</script>
{#if $canWriteRules}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<SearchQuery placeholder="Search by name" />
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<Button
href={`${base}/project-${page.params.region}-${page.params.project}/settings/domains/create`}
event="create_user"
size="s">
<Icon size="s" icon={IconPlus} slot="start" />
<span class="text">Create domain</span>
</Button>
</Layout.Stack>
</Layout.Stack>
{/if}
{#if rules.total}
<Table.Root
let:root
columns={[
{ id: 'name' },
{ id: 'verification' },
{ id: 'certificate' },
{ id: 'action', width: 20, hide: !$canWriteRules }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="verification" {root}>Verification status</Table.Header.Cell>
<Table.Header.Cell column="certificate" {root}>Certificate status</Table.Header.Cell>
<Table.Header.Cell column="action" {root} />
</svelte:fragment>
{#each rules.rules as domain}
<Table.Row.Base {root}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<Link.Anchor href={`https://${domain.domain}`} target="_blank">
{domain.domain}
</Link.Anchor>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="verification" {root}>
{#if domain.status === 'created'}
<Layout.Stack direction="row" alignItems="center">
<Badge variant="secondary" content="failed" type="error">
<Icon slot="start" size="s" icon={IconExclamationCircle} />
</Badge>
<Link.Button variant="muted" on:click={() => openRetry(domain)}>
Retry
</Link.Button>
</Layout.Stack>
{:else}
<Badge content="verified" variant="secondary" type="success">
<Icon slot="start" size="s" icon={IconCheckCircle} />
</Badge>
{/if}
</Table.Cell>
<Table.Cell column="certificate" {root}>
{#if domain.status === 'unverified'}
<Layout.Stack direction="row" alignItems="center">
<Badge variant="secondary" content="failed" type="error">
<Icon slot="start" size="s" icon={IconExclamationCircle} />
</Badge>
<Link.Button variant="muted" on:click={() => openRetry(domain)}>
Retry
</Link.Button>
</Layout.Stack>
{:else if domain.status === 'verified'}
<Layout.Stack direction="row" alignItems="center">
<Badge content="generated" variant="secondary" type="success">
<Icon slot="start" size="s" icon={IconCheckCircle} />
</Badge>
{#if domain.renewAt}
<span class="u-text-color-gray">
Auto-renewal: {toLocaleDate(domain.renewAt)}
</span>
{/if}
</Layout.Stack>
{:else}
<Badge content="blocked by verification" variant="secondary" type="warning">
<Icon slot="start" size="s" icon={IconExclamationCircle} />
</Badge>
{/if}
</Table.Cell>
<Table.Cell column="action" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
{#if domain.status !== 'verified'}
<ActionMenu.Item.Button
leadingIcon={IconRefresh}
on:click={() => openRetry(domain)}>
{domain.status === 'unverified'
? 'Retry generation'
: 'Retry verification'}
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
leadingIcon={IconTrash}
on:click={() => {
selectedDomain = domain;
showDelete = true;
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{:else}
<Empty
single
href="https://appwrite.io/docs/advanced/platform/custom-domains"
target="domain"
on:click={openWizard} />
{/if}
<Delete bind:showDelete bind:selectedDomain {dependency} />
<Modal
bind:show={showRetry}
bind:error={retryError}
title={`Retry ${selectedDomain?.status === 'unverified' ? 'certificate generation' : 'verification'}`}>
<Retry domain={selectedDomain} on:error={(e) => (retryError = e.detail)} />
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showRetry = false)}>Close</Button>
</svelte:fragment>
</Modal>
-2
View File
@@ -1,2 +0,0 @@
export { default as ProxyRulesPage } from './index.svelte';
export { default as Retry } from './wizard/retry.svelte';
@@ -1,38 +0,0 @@
<script lang="ts">
import { Copy } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Table } from '@appwrite.io/pink-svelte';
import { domain } from './store';
import { getProjectEndpoint } from '$lib/helpers/project';
$: parts = $domain.domain.split('.');
$: registerable = [parts[parts.length - 2], parts[parts.length - 1]].join('.');
$: cnameValue = $domain.domain.replace('.' + registerable, '');
$: target = new URL(getProjectEndpoint()).hostname;
</script>
<Table.Root
let:root
columns={[{ id: 'type' }, { id: 'name' }, { id: 'value' }, { id: 'action', width: 40 }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="action" {root} />
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell column="type" {root}>CNAME</Table.Cell>
<Table.Cell column="name" {root}>{cnameValue}</Table.Cell>
<Table.Cell column="value" {root}>
{target}
</Table.Cell>
<Table.Cell column="action" {root}>
<Button text>
<Copy value={target}>
<span class="icon-duplicate" aria-hidden="true"></span>
</Copy>
</Button>
</Table.Cell>
</Table.Row.Base>
</Table.Root>
-92
View File
@@ -1,92 +0,0 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import CnameTable from './cnameTable.svelte';
import { createEventDispatcher } from 'svelte';
import { Code } from '$lib/components';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import type { Models } from '@appwrite.io/console';
import { Card, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
let retrying = false;
export let showTitle = true;
export let domain: Models.ProxyRule;
const dispatch = createEventDispatcher();
async function retry() {
try {
retrying = true;
domain = await sdk
.forProject(page.params.region, page.params.project)
.proxy.updateRuleVerification(domain.$id);
invalidate(Dependencies.FUNCTION_DOMAINS);
addNotification({
message:
domain.status === 'unverified'
? 'Domain certificate has been generated successfully'
: 'Domain has been verified successfully',
type: 'success'
});
trackEvent(Submit.DomainUpdateVerification);
} catch (error) {
dispatch('error', error.message);
trackError(error, Submit.DomainUpdateVerification);
} finally {
retrying = false;
}
}
</script>
{#if domain.status === 'created'}
<Card.Base variant="secondary" padding="s">
<Layout.Stack gap="s" direction="row" justifyContent="space-between" wrap="wrap">
{#if showTitle}
<b>{domain.domain}</b>
{/if}
<Layout.Stack direction="row" gap="s" inline alignItems="center">
<Icon color="--web-red-500" size="s" icon={IconExclamationCircle} />
<Typography.Text>Verification failed</Typography.Text>
</Layout.Stack>
</Layout.Stack>
</Card.Base>
<Typography.Text>
In order to continue, set the following record on your DNS provider. DNS records may take up
to 48 hours to propagate. Please retry over the next 48 hours, but if verification still
fails, please <Link.Anchor
href="https://appwrite.io/discord"
target="_blank"
rel="noopener noreferrer">contact support</Link.Anchor
>.
</Typography.Text>
<CnameTable />
{:else if domain.status === 'unverified'}
<div class="u-flex u-gap-8 u-cross-center">
<span class="icon-exclamation-circle u-color-text-danger" aria-hidden="true"></span>
<p class="u-stretch">Generation failed</p>
<Button secondary on:click={retry} disabled={retrying}>
{#if retrying}
<div class="loader u-text-color-gray"></div>
{:else}
Retry
{/if}
</Button>
</div>
<Typography.Text>
In order to continue, set the following record on your DNS provider. DNS records may take up
to 48 hours to propagate. Please retry over the next 48 hours, but if verification still
fails, please <Link.Anchor
href="https://appwrite.io/discord"
target="_blank"
rel="noopener noreferrer">contact support</Link.Anchor
>.
</Typography.Text>
{#if domain?.logs}
<Code language="sh" withCopy code={domain.logs} />
{/if}
{/if}
-83
View File
@@ -1,83 +0,0 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Button, InputDomain } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { isSelfHosted } from '$lib/system';
import { func } from '$routes/(console)/project-[region]-[project]/functions/function-[function]/store';
import { domain } from './store';
import { consoleVariables } from '$routes/(console)/store';
import { Alert } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
let error = null;
const isDomainsEnabled = $consoleVariables?._APP_DOMAIN_ENABLED === true;
async function createDomain() {
try {
if ($domain.$id) {
await sdk
.forProject(page.params.region, page.params.project)
.proxy.deleteRule($domain.$id);
}
$domain = await sdk
.forProject(page.params.region, page.params.project)
.proxy.createFunctionRule($domain.domain, $func.$id);
trackEvent(Submit.DomainCreate);
} catch (e) {
error = true;
trackError(e.message, Submit.DomainCreate);
throw new Error(e.message);
}
}
</script>
<WizardStep beforeSubmit={createDomain}>
<svelte:fragment slot="title">Domain</svelte:fragment>
<svelte:fragment slot="subtitle">
Use your self-owned domain as the endpoint of your Appwrite API. <a
href="https://appwrite.io/docs/advanced/platform/custom-domains"
target="_blank"
rel="noopener noreferrer"
class="link">Learn more</a
>.
</svelte:fragment>
<InputDomain
id="domain"
label="Domain"
placeholder="appwrite.example.com"
autocomplete={false}
required
bind:value={$domain.domain} />
{#if error}
<div class="common-section">
<p>
You can find a list of domain providers and their DNS setting documentation <a
class="link"
href="https://appwrite.io/docs/advanced/platform/custom-domains#cname-record"
target="_blank"
rel="noreferrer">here</a
>. If your domain provider isn't listed, please contact us, and we'll include their
settings as well.
</p>
</div>
{/if}
{#if isSelfHosted && !isDomainsEnabled}
<Alert.Inline status="info" title="Adding a domain to a self-hosted instance">
To add a domain to a locally hosted Appwrite project, you must first configure your
server domain.
<svelte:fragment slot="actions">
<Button
href="https://appwrite.io/docs/advanced/self-hosting/functions#git"
external
text>
Learn more
</Button>
</svelte:fragment>
</Alert.Inline>
{/if}
</WizardStep>
-54
View File
@@ -1,54 +0,0 @@
<script lang="ts">
import { WizardStep } from '$lib/layout';
import { domain } from './store';
import Retry from './retry.svelte';
import { addNotification } from '$lib/stores/notifications';
import { Box } from '$lib/components';
import type { Models } from '@appwrite.io/console';
function onRetryError(event: CustomEvent<string>) {
addNotification({
message: event.detail,
type: 'error'
});
}
// TODO: @arman, please confirm that its no longer partial at this point.
function domainAsProxyRule(): Models.ProxyRule {
return $domain as Models.ProxyRule;
}
</script>
<WizardStep>
<svelte:fragment slot="title">{$domain.domain}</svelte:fragment>
<div class="boxes-wrapper u-margin-block-start-24">
{#if $domain.status === 'created'}
<Retry domain={domainAsProxyRule()} on:error={onRetryError} showTitle={false} />
{:else}
<div class="u-flex u-gap-8 u-cross-center">
<span class="icon-check u-color-text-success" aria-hidden="true"></span>
<p class="u-stretch">Domain verified</p>
</div>
{/if}
<Box>
<div class="u-flex u-gap-8 u-cross-center">
{#if $domain.status === 'verifying'}
<div
class="loader"
style="color: hsl(var(--color-neutral-50)); inline-size: 1.25rem; block-size: 1.25rem">
</div>
<p class="u-stretch">Generating certificate</p>
{:else if $domain.status === 'verified'}
<span class="icon-check u-color-text-success" aria-hidden="true"></span>
<p class="u-stretch">Certificate generated</p>
{:else}
<span class="icon-clock u-text-color-gray" aria-hidden="true"></span>
<p class="u-stretch">
Certificate generation will begin after domain verification
</p>
{/if}
</div>
</Box>
</div>
</WizardStep>
-6
View File
@@ -1,6 +0,0 @@
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import type { Dependencies } from '$lib/constants';
export const domain = writable<Partial<Models.ProxyRule>>({ $id: '', domain: '' });
export const dependencyStore = writable<Dependencies>();
+2
View File
@@ -278,6 +278,7 @@ export type AddressesList = {
};
export type AdditionalResource = {
name: string;
currency: string;
invoiceDesc: string;
price: number;
@@ -339,6 +340,7 @@ export type Plan = {
backupPolicies: number;
emailBranding: boolean;
supportsCredits: boolean;
supportsOrganizationRoles: boolean;
buildSize: number; // in MB
deploymentSize: number; // in MB
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -63,4 +63,4 @@ export const currentPlan = derived(page, ($page) => $page.data?.currentPlan as P
export const members = derived(page, ($page) => $page.data.members as Models.MembershipList);
export const projectsCount = derived(page, ($page) => $page.data.projects?.length || 0);
export const regions = writable<Models.ConsoleRegionList | undefined>(undefined);
export const regions = writable<Models.ConsoleRegionList>({ total: 0, regions: [] });
+57 -8
View File
@@ -6,6 +6,8 @@ import { sdk } from './sdk';
import type { Models } from '@appwrite.io/console';
import { organization } from './organization';
import { page } from '$app/state';
import { user } from '$lib/stores/user';
import deepEqual from 'deep-equal';
type Preferences = {
limit?: number;
@@ -27,12 +29,39 @@ type PreferencesStore = {
};
} & { hideAiDisclaimer?: boolean };
async function updateConsolePreferences(store: PreferencesStore): Promise<void> {
const currentPreferences = get(user).prefs ?? (await sdk.forConsole.account.getPrefs());
if (!currentPreferences?.console || Array.isArray(currentPreferences.console)) {
currentPreferences.console = {};
}
currentPreferences.console = {
...currentPreferences.console,
...store
};
await sdk.forConsole.account.updatePrefs(currentPreferences);
}
function createPreferences() {
const { subscribe, set, update } = writable<PreferencesStore>({});
let preferences: PreferencesStore = {};
if (browser) {
set(JSON.parse(globalThis.localStorage.getItem('preferences') ?? '{}'));
// fresh fetch.
sdk.forConsole.account
.getPrefs()
.then((userPreferences) => {
if (!userPreferences?.console || Array.isArray(userPreferences.console)) {
userPreferences.console = {};
}
set(userPreferences.console);
})
.catch(() => {
// exception is thrown if there's no session; in that case - fallback!
set(JSON.parse(globalThis.localStorage.getItem('preferences') ?? '{}'));
});
}
subscribe((v) => {
@@ -42,6 +71,28 @@ function createPreferences() {
}
});
/**
* Update the local store and then synchronizes them on user prefs.
*/
function updateAndSync(callback: (prefs: PreferencesStore) => void): Promise<void> {
let oldPrefsSnapshot: PreferencesStore;
let newPrefsSnapshot: PreferencesStore;
update((currentPrefs) => {
oldPrefsSnapshot = currentPrefs;
callback(currentPrefs);
newPrefsSnapshot = currentPrefs;
return currentPrefs;
});
if (deepEqual(oldPrefsSnapshot, newPrefsSnapshot)) {
return;
}
// sync the preferences.
return updateConsolePreferences(newPrefsSnapshot);
}
return {
subscribe,
set,
@@ -61,7 +112,7 @@ function createPreferences() {
return preferences?.collections?.[collectionId] ?? [];
},
setLimit: (limit: Preferences['limit']) =>
update((n) => {
updateAndSync((n) => {
const path = page.route.id;
if (!n?.[path]) {
@@ -74,7 +125,7 @@ function createPreferences() {
return n;
}),
setView: (view: Preferences['view']) =>
update((n) => {
updateAndSync((n) => {
const path = page.route.id;
if (!n?.[path]) {
@@ -87,7 +138,7 @@ function createPreferences() {
return n;
}),
setColumns: (columns: Preferences['columns']) =>
update((n) => {
updateAndSync((n) => {
const path = page.route.id;
if (!n?.[path]) {
@@ -100,10 +151,8 @@ function createPreferences() {
return n;
}),
setCustomCollectionColumns: (columns: Preferences['columns']) =>
update((n) => {
const current = page;
const collection = current.params.collection;
updateAndSync((n) => {
const collection = page.params.collection;
if (!n?.collections?.[collection]) {
n ??= {};
n.collections ??= {};
+37 -19
View File
@@ -20,11 +20,11 @@ import {
Users,
Vcs,
Sites,
Tokens
Tokens,
Domains
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
import { Domains } from '$lib/sdk/domains';
import { Sources } from '$lib/sdk/sources';
import {
REGION_FRA,
@@ -64,14 +64,39 @@ const getSubdomain = (region?: string) => {
}
};
function createConsoleSdk(client: Client) {
return {
client,
account: new Account(client),
avatars: new Avatars(client),
functions: new Functions(client),
health: new Health(client),
locale: new Locale(client),
projects: new Projects(client),
teams: new Teams(client),
users: new Users(client),
migrations: new Migrations(client),
console: new Console(client),
assistant: new Assistant(client),
billing: new Billing(client),
sources: new Sources(client),
sites: new Sites(client),
domains: new Domains(client)
};
}
const endpoint = getApiEndpoint();
const clientConsole = new Client();
const scopedConsoleClient = new Client();
const clientProject = new Client();
const clientRealtime = new Client();
if (!building) {
clientConsole.setEndpoint(endpoint).setProject('console');
scopedConsoleClient.setMode(endpoint).setProject('console');
clientRealtime.setEndpoint(endpoint).setProject('console');
clientProject.setEndpoint(endpoint).setMode('admin');
clientRealtime.setEndpoint(endpoint).setProject('console');
@@ -110,24 +135,17 @@ export const realtime = {
};
export const sdk = {
forConsole: {
client: clientConsole,
account: new Account(clientConsole),
avatars: new Avatars(clientConsole),
functions: new Functions(clientConsole),
health: new Health(clientConsole),
locale: new Locale(clientConsole),
projects: new Projects(clientConsole),
teams: new Teams(clientConsole),
users: new Users(clientConsole),
migrations: new Migrations(clientConsole),
console: new Console(clientConsole),
assistant: new Assistant(clientConsole),
billing: new Billing(clientConsole),
sources: new Sources(clientConsole),
sites: new Sites(clientConsole),
domains: new Domains(clientConsole)
forConsole: createConsoleSdk(clientConsole),
forConsoleIn(region: string) {
const regionAwareEndpoint = getApiEndpoint(region);
if (regionAwareEndpoint !== scopedConsoleClient.config.endpoint) {
scopedConsoleClient.setEndpoint(regionAwareEndpoint);
}
return createConsoleSdk(scopedConsoleClient);
},
forProject(region: string, projectId: string) {
const endpoint = getApiEndpoint(region);
if (endpoint !== clientProject.config.endpoint) {
+46 -21
View File
@@ -7,7 +7,8 @@ import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from './notifications';
import { organization } from './organization';
import { base } from '$app/paths';
import { ThemeLightCloud } from '$themes';
import { ThemeDarkCloud, ThemeLightCloud } from '$themes';
import Color from 'color';
export const stripe = writable<Stripe>();
let paymentMethod: PaymentMethodData;
@@ -185,37 +186,46 @@ export async function confirmSetup(
}
}
function toRGB(color: string): string {
return Color(color).rgb().string();
}
const appearanceLight: Appearance = {
variables: {
fontSizeBase: ThemeLightCloud['font-size-s'],
fontSizeSm: ThemeLightCloud['font-size-s'],
colorPrimary: ThemeLightCloud['neutral-700'],
colorText: ThemeLightCloud['neutral-700'],
colorBackground: 'rgb(250, 250, 251)',
colorDanger: ThemeLightCloud['fgcolor-error'],
fontFamily: ThemeLightCloud['font-family-sansserif'],
colorPrimary: toRGB(ThemeLightCloud['neutral-700']),
colorTextSecondary: toRGB(ThemeLightCloud['neutral-700']),
colorText: toRGB(ThemeLightCloud['neutral-700']),
colorBackground: toRGB(ThemeLightCloud['neutral-25']),
colorDanger: toRGB(ThemeLightCloud['web-red-700']),
fontFamily:
ThemeLightCloud['font-family-sansserif'] +
', system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
borderRadius: ThemeLightCloud['base-8']
},
rules: {
'.Label': {
color: ThemeLightCloud['neutral-700'],
color: toRGB(ThemeLightCloud['neutral-700']),
marginBottom: ThemeLightCloud['base-8'],
fontWeight: '500'
},
'.Input': {
padding: ThemeLightCloud['base-6'],
paddingLeft: ThemeLightCloud['base-12']
paddingLeft: ThemeLightCloud['base-12'],
outlineOffset: '-1px'
},
'.Input:hover': {
border: 'solid 1px rgb(195, 195, 198)',
border: '1px solid ' + toRGB(ThemeLightCloud['neutral-250']),
boxShadow: 'none'
},
'.Input:focus': {
border: 'solid 1px rgb(195, 195, 198)',
border: '1px solid ' + toRGB(ThemeLightCloud['neutral-250']),
outline: '2px solid ' + toRGB(ThemeLightCloud['neutral-250']),
boxShadow: 'none'
},
'.Input::placeholder': {
color: '#C4C6D7'
color: toRGB(ThemeLightCloud['neutral-250'])
},
'.Input--invalid': {
border: 'solid 1px var(--colorDanger)',
@@ -226,25 +236,40 @@ const appearanceLight: Appearance = {
const appearanceDark = {
variables: {
colorPrimary: '#606a7b',
colorText: 'rgb(195, 195, 198)',
colorBackground: 'rgb(24, 24, 27)',
colorDanger: '#FF453A',
fontFamily: 'Inter, arial, sans-serif',
borderRadius: '4px',
spacingGridRow: '16px'
fontSizeBase: ThemeDarkCloud['font-size-s'],
fontSizeSm: ThemeDarkCloud['font-size-s'],
colorPrimary: toRGB(ThemeDarkCloud['neutral-250']),
colorText: toRGB(ThemeDarkCloud['neutral-250']),
colorTextSecondary: toRGB(ThemeDarkCloud['neutral-250']),
colorBackground: toRGB(ThemeDarkCloud['neutral-900']),
colorDanger: toRGB(ThemeDarkCloud['web-red-500']),
fontFamily:
ThemeLightCloud['font-family-sansserif'] +
', system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
borderRadius: ThemeDarkCloud['base-8']
},
rules: {
'.Label': {
color: toRGB(ThemeDarkCloud['neutral-250']),
marginBottom: ThemeDarkCloud['base-8'],
fontWeight: '500'
},
'.Input': {
padding: ThemeDarkCloud['base-6'],
paddingLeft: ThemeDarkCloud['base-12'],
outlineOffset: '-1px'
},
'.Input:hover': {
border: 'solid 1px rgb(87, 87, 92)',
border: '1px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
boxShadow: 'none'
},
'.Input:focus': {
border: 'solid 1px rgb(87, 87, 92)',
border: '1px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
outline: '2px solid ' + toRGB(ThemeDarkCloud['neutral-500']),
boxShadow: 'none'
},
'.Input::placeholder': {
color: 'rgb(87, 87, 92)'
color: toRGB(ThemeDarkCloud['neutral-500'])
},
'.Input--invalid': {
border: 'solid 1px var(--colorDanger)',
+1
View File
@@ -7,6 +7,7 @@ import type { NotificationPrefItem } from '$lib/helpers/notifications';
export type Account = Models.User<
{
organization?: string;
console: Models.Preferences;
notificationPrefs: Record<string, NotificationPrefItem>;
} & Record<string, string>
>;
+2
View File
@@ -101,6 +101,8 @@ function createWizardStore() {
export const wizard = createWizardStore();
export const isNewWizardStatusOpen = writable(false);
export function updateStepStatus(map: WizardStepsType, key: number, status: boolean) {
const updatedComponent = {
...map.get(key),
@@ -3,6 +3,7 @@ import { wizard } from '$lib/stores/wizard';
import { requestedMigration } from '$routes/store';
import { get, writable } from 'svelte/store';
import Wizard from './wizard.svelte';
import { Region } from '@appwrite.io/console';
export const formData = createMigrationFormStore();
@@ -18,5 +19,6 @@ export function openMigrationWizard() {
}
export const selectedProject = writable<string>(null);
export const selectedRegion = writable<Region>(Region.Fra);
export const provider = createMigrationProviderStore();
@@ -1,66 +1,27 @@
<script lang="ts">
import { Alert, Box, EyebrowHeading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { deepMap } from '$lib/helpers/object';
import { Alert, Divider, Layout } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import {
createMigrationFormStore,
createMigrationProviderStore,
isVersionAtLeast,
type MigrationFormData,
providerResources,
resourcesToMigrationForm
} from '$lib/stores/migration';
import { Button } from '$lib/elements/forms';
import { wizard } from '$lib/stores/wizard';
import type { Models } from '@appwrite.io/console';
import type { sdk } from '$lib/stores/sdk';
import type { WritableValue } from '$lib/helpers/types';
import ImportReport from '$routes/(console)/project-[region]-[project]/settings/migrations/(import)/importReport.svelte';
export let errorInResources: boolean | undefined = undefined;
export let formData: ReturnType<typeof createMigrationFormStore>;
export let provider: ReturnType<typeof createMigrationProviderStore>;
export let projectSdk: ReturnType<typeof sdk.forProject>;
type ValueOf<T> = T[keyof T];
type FormData = WritableValue<typeof formData>;
// TL;DR of this type: It gets a object with two levels (in this case FormData)
// And returns a join of the keys with a dot between them.
// e.g. If FormData was { users: { root: boolean, teams: boolean } }
// The type would be 'users.root' | 'users.teams'
type FormKeys = ValueOf<{
[K in keyof FormData]: ValueOf<{
[L in keyof FormData[K]]: L extends string ? `${K}.${L}` : never;
}>;
}>;
/**
*
* @param field {string} The field to update.
* Represents the path to the field in the formData object e.g. 'users.root'
*/
function handleInputChange(field: FormKeys) {
return (event: Event) => {
const checked = (event.target as HTMLInputElement).checked;
// For each entry in formData, if the root is changed, change all children to the same value
// otherwise, if a child is changed to true, change the root to true
const [parent, child] = field.split('.');
if (child === 'root') {
formData.update((data) => {
data[parent].root = checked;
for (const key in data[parent]) {
if (key !== 'root') {
data[parent][key] = checked;
}
}
return data;
});
} else if (checked) {
formData.update((data) => {
data[parent].root = checked;
return data;
});
}
};
}
export let migrationType: 'cloud' | 'provider' = 'provider';
function deselectAll() {
$formData = deepMap($formData, (v) => {
@@ -75,14 +36,15 @@
$formData = resourcesToMigrationForm(resources, version);
}
let report: Models.MigrationReport;
$: version = report?.version || '0.0.0';
let isOpen = false;
let error = false;
let isOpen = false;
let report: Models.MigrationReport;
onMount(async () => {
isOpen = true;
deselectAll();
try {
switch ($provider.provider) {
case 'appwrite':
@@ -109,7 +71,6 @@
providerResources.firebase,
$provider.serviceAccount
);
break;
case 'nhost':
report = await projectSdk.migrations.getNHostReport(
@@ -130,322 +91,84 @@
isOpen = false;
});
$: resources = providerResources[$provider.provider];
$: errorInResources = error;
$: wizard.setNextDisabled(!report);
$: resources = providerResources[$provider.provider];
const shouldRenderGroup = (groupKey: string): boolean => {
if (groupKey === 'functions') {
return resources.includes('function') && isVersionAtLeast(version, '1.4.0');
}
if (groupKey === 'storage') {
return resources.includes('bucket') && resources.includes('file');
}
return resources.includes(groupKey.slice(0, -1));
};
// no typecasting in svelte context!
function getAsType(groupKey: string) {
return groupKey as keyof MigrationFormData;
}
const getReportKey = (groupKey: string) => {
const map: Record<string, string> = {
users: 'user',
databases: 'database',
functions: 'function',
storage: 'bucket'
};
return map[groupKey] || groupKey;
};
</script>
<Box radius="s">
<div class="u-flex u-flex-vertical u-gap-16">
<EyebrowHeading class="eyebrow" tag="h3" size={3}>Good to know</EyebrowHeading>
<div class="u-flex u-gap-16">
<div class="circled">
<i class="icon-cog"></i>
</div>
<div>
<p class="u-bold">Project settings are not imported</p>
<p>You will need to set service and project settings manually</p>
</div>
</div>
<div class="u-flex u-gap-16">
<div class="circled">
<i class="icon-trending-up"></i>
</div>
<div>
<p class="u-bold">Keep your organization plan's limits in mind</p>
<p>
Make sure to have enough storage in your organization plan when importing files.
</p>
</div>
</div>
{#if $provider.provider === 'firebase'}
<div class="u-flex u-gap-16">
<div class="circled">
<i class="icon-exclamation u-color-text-warning"></i>
</div>
<div>
<p class="u-bold">Possible charges by Firebase</p>
<p>
Appwrite does not impose charges for importing data, but please note that
Firebase may have its own pricing for this service
</p>
</div>
</div>
{:else}
<div class="u-flex u-gap-16">
<div class="circled">
<i class="icon-currency-dollar"></i>
</div>
<div>
<p class="u-bold">Transfer is free of charge</p>
<p>You won't be charged for Appwrite bandwidth usage for importing data</p>
</div>
</div>
{/if}
</div>
</Box>
{#if report && !isVersionAtLeast(version, '1.4.0') && $provider.provider === 'appwrite'}
<div class="u-margin-block-start-24">
<Alert
type="warning"
isStandalone
buttons={[
{
slot: 'Learn more',
onClick() {
wizard.updateStep((p) => p - 1);
}
}
]}>
<Layout.Stack gap="l">
{#if report && !isVersionAtLeast(version, '1.4.0') && $provider.provider === 'appwrite'}
<Alert.Inline status="warning">
<svelte:fragment slot="title">Functions not available for import</svelte:fragment>
To migrate your functions, update the version of the Appwrite instance you're importing from
to a version newer than 1.4
</Alert>
</div>
{/if}
</Alert.Inline>
{/if}
{#if error}
<div class="u-margin-block-start-24">
<Alert
type="error"
isStandalone
buttons={[
{
slot: 'Edit credentials',
onClick() {
wizard.updateStep((p) => p - 1);
}
}
]}>
<svelte:fragment slot="title">Request failed</svelte:fragment>
Please check if your credentials are filled in correctly in the previous step
</Alert>
</div>
{/if}
<ul class="buttons-list u-margin-block-start-32 u-main-end">
<li class="buttons-list-item">
<Button text on:click={deselectAll}>Deselect all</Button>
</li>
<li class="buttons-list-item">
<Button text on:click={selectAll}>Select all</Button>
</li>
</ul>
<ul class="u-flex u-flex-vertical u-margin-block-start-16">
{#if resources?.includes('user')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.users.root}
on:change={handleInputChange('users.root')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Users</span>
{#if $provider.provider !== 'firebase'}
{#if report?.user !== undefined}
<span class="inline-tag">{report.user}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<span>Import all users</span>
{#if resources?.includes('team')}
<ul>
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.users.teams}
on:change={handleInputChange('users.teams')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Include teams</span>
{#if $provider.provider === 'firebase'}
{#if report?.team !== undefined}
<span class="inline-tag">{report.team}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<span>Import all teams and the team memberships of your users</span>
</li>
</ul>
{#if error}
<Alert.Inline status="error" title="Couldnt load resources">
{#if migrationType === 'provider'}
Please double-check your credentials from the previous step and try again.
{:else}
The API key required for the migration may no longer be valid or has expired, please
verify and start migration again.
{/if}
</li>
{/if}
</Alert.Inline>
{:else}
<Layout.Stack gap="l">
<Layout.Stack direction="row">
<Button
compact
on:click={(event) => {
event.preventDefault();
deselectAll();
}}>Deselect all</Button>
{#if resources?.includes('database')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.databases.root}
on:change={handleInputChange('databases.root')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Databases</span>
{#if $provider.provider !== 'firebase'}
{#if report?.database !== undefined}
<span class="inline-tag">{report.database}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<span>Import all databases, including collections, indexes and attributes</span>
<Button
compact
on:click={(event) => {
event.preventDefault();
selectAll();
}}>Select all</Button>
</Layout.Stack>
<Divider />
</Layout.Stack>
{#if resources?.includes('document')}
<ul>
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.databases.documents}
on:change={handleInputChange('databases.documents')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Include documents</span>
{#if $provider.provider !== 'firebase'}
{#if report?.document !== undefined}
<span class="inline-tag">{report.document}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<span>Import all of your documents</span>
</li>
</ul>
{#each Object.entries($formData) as [groupKey, formGroup]}
{#if shouldRenderGroup(groupKey)}
<ImportReport
{error}
{formGroup}
groupKey={getAsType(groupKey)}
reportValue={report?.[getReportKey(groupKey)]} />
{/if}
</li>
{/each}
{/if}
{#if resources?.includes('function') && isVersionAtLeast(version, '1.4.0')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.functions.root}
on:change={handleInputChange('functions.root')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Functions</span>
{#if $provider.provider !== 'firebase'}
{#if report?.function !== undefined}
<span class="inline-tag">{report.function}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<span>Import all functions and their active deployment</span>
<ul>
{#if resources?.includes('environment-variable')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.functions.env}
on:change={handleInputChange('functions.env')} />
<div class="u-flex u-gap-4">
<span class="u-bold">Include environment variables</span>
</div>
<div></div>
<span>Import all environment variables</span>
</li>
{/if}
{#if resources?.includes('deployment')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.functions.inactive}
on:change={handleInputChange('functions.inactive')} />
<div class="u-flex u-gap-4">
<span class="u-bold">Include inactive deployments</span>
</div>
<div></div>
<span>Import all deployments that are not currently active</span>
</li>
{/if}
</ul>
</li>
{/if}
{#if resources?.includes('bucket') && resources?.includes('file')}
<li class="checkbox-field">
<input
type="checkbox"
bind:checked={$formData.storage.root}
on:change={handleInputChange('storage.root')} />
<div class="u-flex u-gap-4 u-cross-center">
<span class="u-bold">Storage</span>
{#if $provider.provider !== 'firebase'}
{#if report?.size !== undefined}
<span class="inline-tag">{`${report.size.toFixed(2)}MB`}</span>
{:else if !error}
<span class="loader is-small u-margin-inline-start-4"></span>
{/if}
{/if}
</div>
<div></div>
<p>
Import all buckets
{#if $provider.provider !== 'firebase' && report?.bucket}
<span class="inline-tag">{report.bucket}</span>
{/if}
and files
{#if $provider.provider !== 'firebase' && report?.file}
<span class="inline-tag">{report.file}</span>
{/if}
</p>
</li>
{/if}
</ul>
<!-- svelte-ignore css-unused-selector -->
<style lang="scss">
.box :global(.eyebrow) {
font-weight: 500;
color: hsl(var(--color-neutral-70));
}
.circled {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border-radius: 100%;
border: 1px solid hsl(var(--border));
position: relative;
i {
position: absolute;
left: 50%;
top: 50%;
translate: -50% -50%;
font-size: 1rem;
}
}
.buttons-list {
border-block-end: 1px solid hsl(var(--border));
padding-block-end: 0.625rem;
}
.checkbox-field {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
align-items: center;
padding-block-end: 1rem;
ul {
grid-column-start: 2;
li {
margin-block-start: 1rem;
}
}
}
</style>
</Layout.Stack>
@@ -1,52 +1,388 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
import Wizard from '$lib/layout/wizardWithSteps.svelte';
import { migrationFormToResources } from '$lib/stores/migration';
import { wizard } from '$lib/stores/wizard';
import { onDestroy } from 'svelte';
import { formData, provider, selectedProject } from '.';
import Step1 from './step1.svelte';
import Step2 from './step2.svelte';
import { requestedMigration } from '$routes/store';
import { base } from '$app/paths';
import { page } from '$app/state';
import { Wizard } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { capitalize } from '$lib/helpers/string';
import ResourceForm from './resource-form.svelte';
import { requestedMigration } from '$routes/store';
import { formData, provider, selectedProject, selectedRegion } from '.';
import { ID, type Models, Query } from '@appwrite.io/console';
import { InputSelect, InputText } from '$lib/elements/forms';
import {
Button,
Card,
Fieldset,
Icon,
Input,
Layout,
Skeleton,
Spinner,
Typography
} from '@appwrite.io/pink-svelte';
import { isCloud } from '$lib/system';
import { regions } from '$lib/stores/organization';
import { addNotification } from '$lib/stores/notifications';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { migrationFormToResources } from '$lib/stores/migration';
import { EyebrowHeading } from '$lib/components';
import {
IconAppwrite,
IconCog,
IconCurrencyDollar,
IconExclamation,
IconTrendingUp
} from '@appwrite.io/pink-icons-svelte';
import { Dependencies } from '$lib/constants';
import { filterRegions } from '$lib/helpers/regions';
const onExit = () => {
formData.reset();
requestedMigration.set(null);
};
const organizations = page.data.organizations
.teams as Models.TeamList<Models.Preferences>['teams'];
let showExitModal = false;
let showResources = false;
let loadingProjects = false;
let creatingProject = false;
let errorInResources = false;
let migrationStarted = false;
let projectSdkInstance: ReturnType<typeof sdk.forProject> | null = null;
let projects = [] as Models.ProjectList['projects'];
let selectedOrg = organizations.length ? organizations[0].$id : null;
let newProjName = '';
let projectType: 'existing' | 'new' = 'existing';
async function getProjects(orgId: string | null) {
if (!orgId) {
projects = [];
} else {
loadingProjects = true;
projects = await sdk.forConsole.projects
.list([Query.equal('teamId', orgId), Query.orderDesc('$createdAt')])
.then((res) => res.projects);
loadingProjects = false;
projectType = projects.length ? 'existing' : 'new';
if (projectType === 'existing') {
const first = projects[0];
$selectedProject = first.$id;
projectSdkInstance = sdk.forProject(first.region, first.region);
}
}
}
function getProjectName(): string {
return isExisting ? currentSelectedProject.name : newProjName || 'Appwrite project';
}
async function createNewProject() {
creatingProject = true;
try {
return await sdk.forConsole.projects.create(
ID.unique(),
newProjName,
selectedOrg,
$selectedRegion
);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
return null;
} finally {
creatingProject = false;
}
}
const onFinish = async () => {
if ($provider.provider !== 'appwrite') return;
migrationStarted = true;
const resources = migrationFormToResources($formData, $provider.provider);
await sdk
.forProject('fra1', $selectedProject)
.migrations.createAppwriteMigration(
try {
await projectSdkInstance.migrations.createAppwriteMigration(
resources,
$provider.endpoint,
$provider.projectID,
$provider.apiKey
);
await invalidateAll();
wizard.hide();
goto(`${base}/project-${$selectedProject}/settings/migrations`);
addNotification({
type: 'success',
message: 'Migration started'
});
onExit();
await invalidate(Dependencies.PROJECTS);
await goto(
`${base}/project-${currentSelectedProject.region}-${currentSelectedProject.$id}/settings/migrations`
);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
migrationStarted = false;
}
};
onDestroy(onExit);
$: getProjects(selectedOrg);
const steps: WizardStepsType = new Map();
steps.set(1, {
label: 'Project',
component: Step1
});
steps.set(2, {
label: 'Resources',
component: Step2
});
$: currentSelectedProject = projects.find((project) => project.$id === $selectedProject);
$: isExisting = projectType === 'existing';
$: if (isExisting && $selectedProject) {
projectSdkInstance = sdk.forProject(
currentSelectedProject.region,
currentSelectedProject.$id
);
}
$: disableNextButton = isExisting
? !$selectedProject
: newProjName.trim() === '' || creatingProject;
$: isFinalsButtonEnabled =
showResources &&
!errorInResources &&
!disableNextButton &&
Object.values($formData).some((category) =>
Object.values(category).some((value) => value === true)
);
</script>
<Wizard title="Create Migration" {steps} on:exit={onExit} on:finish={onFinish} />
<Wizard title="Create migration" bind:showExitModal confirmExit {onExit}>
<Layout.Stack gap="xl">
{#if !showResources}
<Layout.Stack gap="xxl">
{#if organizations.length >= 1}
<Fieldset legend="Organization">
<InputSelect
required
id="organization"
bind:value={selectedOrg}
label="Select organization"
options={organizations.map((project) => ({
label: project.name,
value: project.$id
}))}
disabled={loadingProjects} />
</Fieldset>
{/if}
<Fieldset legend="Project">
{#if loadingProjects}
<Layout.Stack gap="xl">
<Layout.Grid rowGap="l" columns={2} columnsS={1}>
<Skeleton variant="line" width="100%" height={70} />
<Skeleton variant="line" width="100%" height={70} />
</Layout.Grid>
<Skeleton variant="line" width="100%" height={50} />
</Layout.Stack>
{:else}
<Layout.Stack gap="xl">
<Layout.Grid rowGap="l" columns={2} columnsXS={1}>
<Card.Selector
bind:group={projectType}
name="Existing project"
id="existing"
value="existing"
title="Existing project"
imageRadius="s">
Import data to an existing project
</Card.Selector>
<Card.Selector
bind:group={projectType}
name="Existing project"
id="new"
value="new"
title="Create new project"
imageRadius="s">
Import data to a new project
</Card.Selector>
</Layout.Grid>
{#if projectType === 'existing'}
<InputSelect
required
id="project"
label="Select a project"
bind:value={$selectedProject}
options={projects.map((p) => ({
label: p.name,
value: p.$id
}))} />
{:else}
<Layout.Stack gap="l">
<InputText
required
id="project_name"
label="Project name"
bind:value={newProjName}
placeholder="Enter project name" />
{#if isCloud && $regions.regions.length > 0}
<Input.Select
required
label="Region"
options={filterRegions($regions.regions)}
bind:value={$selectedRegion}
placeholder="Select a region" />
<Typography.Text
>Region cannot be changed after creation</Typography.Text>
{/if}
</Layout.Stack>
{/if}
<Layout.Stack direction="row" justifyContent="flex-end">
<Button.Button
size="s"
disabled={disableNextButton}
on:click={async () => {
if (isExisting) {
showResources = true;
} else {
const project = await createNewProject();
if (project !== null) {
projectSdkInstance = sdk.forProject(
project.region,
project.$id
);
showResources = true;
}
}
}}>
{#if creatingProject}
<Spinner size="s" />
{/if}
Next
</Button.Button>
</Layout.Stack>
</Layout.Stack>
{/if}
</Fieldset>
</Layout.Stack>
{:else}
<Card.Base radius="s" padding="s">
<Layout.Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="xs">
<Layout.Stack gap="s" direction="row" alignItems="center">
<Icon icon={IconAppwrite} color="--fgcolor-neutral-primary" />
<Typography.Text variant="m-500">
{capitalize(getProjectName())}
</Typography.Text>
</Layout.Stack>
<Button.Button
size="s"
variant="secondary"
on:click={() => (showResources = !showResources)}>
Update
</Button.Button>
</Layout.Stack>
</Card.Base>
{/if}
{#if showResources}
<Fieldset legend="Resources">
<Layout.Stack gap="xl">
<ResourceForm
{provider}
{formData}
bind:errorInResources
migrationType="cloud"
projectSdk={projectSdkInstance} />
</Layout.Stack>
</Fieldset>
{/if}
</Layout.Stack>
<svelte:fragment slot="aside">
<Card.Base variant="primary" radius="s" padding="m">
<Layout.Stack gap="l">
<EyebrowHeading class="eyebrow" tag="h3" size={3}>Good to know</EyebrowHeading>
<!-- tip 1 -->
<Layout.Stack gap="l" direction="row" wrap="normal">
<span style:padding-block="2px">
<Icon icon={IconCog} size="s" />
</span>
<Layout.Stack gap="none">
<Typography.Text variant="m-600"
>Project settings are not imported</Typography.Text>
You will need to set service and project settings manually.
</Layout.Stack>
</Layout.Stack>
<!-- tip 2 -->
<Layout.Stack gap="l" direction="row" wrap="normal">
<span style:padding-block="2px">
<Icon icon={IconTrendingUp} size="s" />
</span>
<Layout.Stack gap="none">
<Typography.Text variant="m-600"
>Keep your organization plan's limits in mind</Typography.Text>
Make sure to have enough storage in your organization plan when importing files.
</Layout.Stack>
</Layout.Stack>
<!-- tip 3 -->
<Layout.Stack gap="l" direction="row" wrap="normal">
{@const isFirebase = $provider.provider === 'firebase'}
<span style:padding-block="3px">
<Icon
icon={isFirebase ? IconExclamation : IconCurrencyDollar}
size="s"
color={isFirebase ? '--fgcolor-warning' : undefined} />
</span>
<Layout.Stack gap="none">
<Typography.Text variant="m-600">
{isFirebase
? 'Possible charges by Firebase'
: 'Transfer is free of charge'}
</Typography.Text>
{#if isFirebase}
Appwrite does not impose charges for importing data, but please note
that Firebase may have its own pricing for this service
{:else}
You won't be charged for Appwrite bandwidth usage for importing data
{/if}
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</Card.Base>
</svelte:fragment>
<svelte:fragment slot="footer">
<Button.Button variant="secondary" on:click={() => (showExitModal = true)}>
Cancel
</Button.Button>
<Button.Button
variant="primary"
on:click={onFinish}
disabled={!isFinalsButtonEnabled || migrationStarted}>
{#if migrationStarted}
<Spinner size="s" />
{/if}
Create
</Button.Button>
</svelte:fragment>
</Wizard>
+10 -7
View File
@@ -53,6 +53,8 @@
IconSparkles,
IconSwitchHorizontal
} from '@appwrite.io/pink-icons-svelte';
import type { LayoutData } from './$types';
import type { NavbarProject } from '$lib/components/navbar.svelte';
function kebabToSentenceCase(str: string) {
return str
@@ -63,17 +65,18 @@
const isAssistantEnabled = $consoleVariables?._APP_ASSISTANT_ENABLED === true;
export let data;
export let data: LayoutData;
$: loadedProjects = data.projects.map((project) => {
return {
name: project?.name,
$id: project.$id,
isSelected: project.$id === page.params.project,
region: project.region,
isSelected: data.currentProjectId === project.$id,
platformCount: project.platforms.length,
pingCount: project.pingCount
};
});
}) satisfies NavbarProject[];
$: isOnSettingsLayout = $project?.$id
? page.url.pathname.includes(`project-${$project.region}-${$project.$id}/settings`)
@@ -340,10 +343,10 @@
!page.url.pathname.includes('/console/account') &&
!page.url.pathname.includes('/console/card') &&
!page.url.pathname.includes('/console/onboarding')}
showHeader={!page.url.pathname.includes('/console/onboarding')}
showFooter={!page.url.pathname.includes('/console/onboarding')}
bind:loadedProjects
bind:projects={data.projects}>
showHeader={!page.url.pathname.includes('/console/onboarding/create-project')}
showFooter={!page.url.pathname.includes('/console/onboarding/create-project')}
{loadedProjects}
selectedProject={page.data?.project}>
<!-- <Header slot="header" />-->
<slot />
<Footer slot="footer" />
+3 -3
View File
@@ -4,7 +4,7 @@ import type { Tier } from '$lib/stores/billing';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';
import { Query } from '@appwrite.io/console';
import { Query, type Models } from '@appwrite.io/console';
export const load: LayoutLoad = async ({ params, fetch, depends, parent }) => {
await parent();
@@ -38,7 +38,7 @@ export const load: LayoutLoad = async ({ params, fetch, depends, parent }) => {
? await sdk.forConsole.teams.list()
: await sdk.forConsole.billing.listOrganization();
let projects = [];
let projects: Models.Project[] = [];
let currentOrgId = params.organization ? params.organization : prefs.organization;
if (!currentOrgId && organizations.teams.length > 0) {
@@ -47,7 +47,7 @@ export const load: LayoutLoad = async ({ params, fetch, depends, parent }) => {
if (currentOrgId) {
const orgProjects = await sdk.forConsole.projects.list([
Query.equal('teamId', currentOrgId),
Query.limit(100),
Query.limit(5),
Query.orderDesc('$updatedAt')
]);
projects = orgProjects.projects.length > 0 ? orgProjects.projects : [];
+1 -1
View File
@@ -41,7 +41,7 @@
...permanentTabs,
{
href: `${path}/payments`,
title: 'Payment details',
title: 'Payments',
event: 'payments',
hasChildren: true
}
+1 -1
View File
@@ -75,7 +75,7 @@
{/await}
{:else}
<label for="code">Enter the 6-digit one-time code generated by the app</label>
<InputDigits bind:value={code} autofocus />
<InputDigits required bind:value={code} autofocus />
{/if}
{/key}
<svelte:fragment slot="footer">
@@ -1,6 +1,5 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { page } from '$app/state';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Dependencies } from '$lib/constants';
@@ -28,10 +27,8 @@
let error: string = null;
onMount(async () => {
const countryList = await sdk
.forProject(page.params.region, page.params.project)
.locale.listCountries();
const locale = await sdk.forProject(page.params.region, page.params.project).locale.get();
const countryList = await sdk.forConsole.locale.listCountries();
const locale = await sdk.forConsole.locale.get();
if (locale.countryCode) {
country = locale.countryCode;
}
@@ -28,7 +28,6 @@
Tag,
Typography
} from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
let show = false;
let showEdit = false;
@@ -38,9 +37,7 @@
let countryList: Models.CountryList;
onMount(async () => {
countryList = await sdk
.forProject(page.params.region, page.params.project)
.locale.listCountries();
countryList = await sdk.forConsole.locale.listCountries();
});
$: orgList = $organizationList.teams as unknown as Organization[];
@@ -22,9 +22,7 @@
];
onMount(async () => {
const countryList = await sdk
.forProject(page.params.region, page.params.project)
.locale.listCountries();
const countryList = await sdk.forConsole.locale.listCountries();
options = countryList.countries.map((country) => {
return {
value: country.code,
@@ -40,6 +40,15 @@
$: filteredMethods = $paymentMethods?.paymentMethods.filter(
(method: PaymentMethodData) => !!method?.last4
);
const isMethodLinkedToOrg = (methodId: string, org: Organization) =>
methodId === org.paymentMethodId || methodId === org.backupPaymentMethodId;
$: linkedMethodIds = new Set(
orgList.flatMap((org) => [org.paymentMethodId, org.backupPaymentMethodId].filter(Boolean))
);
$: hasLinkedOrgs = filteredMethods.some((method) => linkedMethodIds.has(method.$id));
$: hasPaymentError = filteredMethods.some((method) => method?.lastError || method?.expired);
</script>
<CardGrid>
@@ -50,25 +59,26 @@
<Table.Root
let:root
columns={[
{ id: 'cc' },
{ id: 'name' },
{ id: 'expiry' },
{ id: 'status' },
{ id: 'links' },
{ id: 'cc', width: 140 },
{ id: 'name', width: { min: 140 } },
{ id: 'expiry', width: 100 },
{ id: 'status', width: 110, hide: !hasPaymentError },
{ id: 'links', width: 190, hide: !hasLinkedOrgs },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="cc" {root}>Credit card</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="expiration" {root}
>Expiration date</Table.Header.Cell>
<Table.Header.Cell column="expiration" {root}>
Expiration date
</Table.Header.Cell>
<Table.Header.Cell column="status" {root} />
<Table.Header.Cell column="links" {root} />
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each filteredMethods as paymentMethod, i}
{@const linkedOrgs = orgList?.filter(
(org) =>
paymentMethod.$id === org.paymentMethodId ||
paymentMethod.$id === org.backupPaymentMethodId
{@const linkedOrgs = orgList?.filter((org) =>
isMethodLinkedToOrg(paymentMethod.$id, org)
)}
<Table.Row.Base {root}>
@@ -13,7 +13,6 @@
import MfaRecoveryCodes from './mfaRecoveryCodes.svelte';
import type { Models } from '@appwrite.io/console';
import MfaRegenerateCodes from './mfaRegenerateCodes.svelte';
import { Pill } from '$lib/elements';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { Badge, Divider, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
@@ -160,11 +159,11 @@
<span class="icon-device-mobile" aria-hidden="true"></span>
</div>
<Layout.Stack gap="xxxs">
<Layout.Stack gap="s">
<Layout.Stack gap="s" direction="row">
<Typography.Text variant="m-600"
>Authenticator app</Typography.Text>
{#if $factors.totp}
<Pill>connected</Pill>
<Badge variant="secondary" content="connected" size="s" />
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
+36 -27
View File
@@ -8,7 +8,7 @@
import { Button, Form, InputSelect, InputTags, InputText } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { Wizard } from '$lib/layout';
import { type PaymentList, type Plan } from '$lib/sdk/billing';
import type { PaymentList, Plan } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import {
organizationList,
@@ -72,12 +72,12 @@
let tempOrgId = null;
let currentPlan: Plan;
$: onlyNewOrgs = (campaign && campaign.onlyNewOrgs) || (couponData && couponData.onlyNewOrgs);
$: onlyNewOrgs = campaign?.onlyNewOrgs || couponData?.onlyNewOrgs;
$: selectedOrgId = tempOrgId;
function isUpgrade() {
const newPlan = page.data.plansInfo.get(billingPlan);
const newPlan = $plansInfo.get(billingPlan);
return currentPlan && newPlan && currentPlan.order < newPlan.order;
}
@@ -174,7 +174,7 @@
'',
clientSecret,
paymentMethodId,
'/console/apply-credit?' + params.toString()
`/console/apply-credit?${params}`
);
org = await sdk.forConsole.billing.validateOrganization(org.teamId, collaborators);
}
@@ -205,7 +205,7 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
coupon = null;
addNotification({
@@ -227,33 +227,40 @@
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;
const newPlan = $plansInfo.get(billingPlan);
if (!campaignPlan && !orgPlan) {
return;
// if campaign has a plan and it's higher than the selected new plan
if (campaignPlan?.order > newPlan?.order) {
return campaignPlan.$id as Tier;
}
if (!campaignPlan) {
return selectedOrg?.billingPlan;
// if current plan's order is higher than the selected new plan
if (currentPlan?.order > newPlan?.order) {
return currentPlan.$id as Tier;
}
if (!orgPlan) {
return campaign.plan;
}
return campaignPlan.order > orgPlan.order ? campaign.plan : selectedOrg?.billingPlan;
return billingPlan;
}
$: if (selectedOrg) {
$: if (currentPlan) {
billingPlan = getBillingPlan();
}
$: {
if (selectedOrgId) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
})();
}
$: isNewOrg = !(selectedOrgId && selectedOrgId !== newOrgId);
$: if (!isNewOrg) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
})();
loadPaymentMethods();
}
// after adding a payment method, fetch the payment methods again so the input can be updated
$: if (
paymentMethodId &&
!methods?.paymentMethods?.find((method) => method.$id === paymentMethodId)
) {
loadPaymentMethods();
}
</script>
@@ -361,9 +368,9 @@
fixedCoupon={!!data?.couponData?.code}
{billingPlan}
{collaborators}
plans={$plansInfo}
bind:couponData
bind:billingBudget>
bind:billingBudget
organizationId={!isNewOrg ? selectedOrgId : null}>
{#if campaign?.template === 'review' && (campaign?.cta || campaign?.claimed || campaign?.unclaimed)}
<div class="u-margin-block-end-24">
<p class="body-text-1 u-bold">{campaign?.cta}</p>
@@ -389,13 +396,15 @@
handleSubmit();
}
}}
disabled={$isSubmitting}>
disabled={!couponData?.code || $isSubmitting}>
{#if $isSubmitting}
<span class="loader is-small is-transparent u-line-height-1-5" aria-hidden="true"
></span>
{/if}
{#if selectedOrgId === newOrgId}
Create organization
{:else if isUpgrade()}
Upgrade
{:else}
Apply
{/if}
+5 -110
View File
@@ -1,124 +1,19 @@
import Init3KeysPromo from '$lib/images/promos/init3-keys.png';
import Init3SitesPromo from '$lib/images/promos/init3-sites.png';
import Init3FlutterPromo from '$lib/images/promos/init3-flutter.png';
import Init3ImagesPromo from '$lib/images/promos/init3-images.png';
import Init3TokensPromo from '$lib/images/promos/init3-tokens.png';
import {
type BottomModalAlertItem,
setMobileSingleAlertLayout,
showBottomModalAlert
} from '$lib/stores/bottom-alerts';
import { isCloud } from '$lib/system';
import { isSameDay } from '$lib/helpers/date';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
const listOfPromotions: BottomModalAlertItem[] = [];
if (!isCloud) {
const getSrc = (image: string) => {
return {
dark: image,
light: image
};
};
const sharedCTA = (url: string) => ({
external: true,
link: () => url,
hideOnClick: true,
text: 'Read the announcement'
});
const promos: BottomModalAlertItem[] = [
{
id: 'modal:init3_sites',
src: getSrc(Init3SitesPromo),
title: 'Announcing: Sites',
message:
'The open-source Vercel alternative. With Sites, you deploy and host your websites and web apps right inside Appwrite.',
plan: 'free',
importance: 8,
scope: 'everywhere',
cta: sharedCTA('https://apwr.dev/sites1'),
// Day 1 = Monday 9 AM CET
show: isPromoLive('2025-05-19', '09:00')
},
{
id: 'modal:init3_flutter',
src: getSrc(Init3FlutterPromo),
title: 'Announcing: Hosting for Flutter Web',
message: 'Deploy your Flutter web apps directly from Appwrite Sites.',
plan: 'free',
importance: 8,
scope: 'everywhere',
cta: sharedCTA('https://apwr.dev/sitesfl'),
// Day 2 = Tuesday 3 PM CET
show: isPromoLive('2025-05-20', '15:00')
},
{
id: 'modal:init3_keys',
src: getSrc(Init3KeysPromo),
title: 'Announcing: Dev Keys',
message:
'A new Appwrite feature that lets you bypass rate limits during local development.',
plan: 'free',
importance: 8,
scope: 'everywhere',
cta: sharedCTA('https://apwr.dev/devkeys'),
// Day 3 = Wednesday 3 PM CET
show: isPromoLive('2025-05-21', '15:00')
},
{
id: 'modal:init3_images',
src: getSrc(Init3ImagesPromo),
title: 'Announcing: New image formats',
message:
'We have added support for two new image formats in Appwrite Storage: HEIC and AVIF.',
plan: 'free',
importance: 8,
scope: 'everywhere',
cta: sharedCTA('https://apwr.dev/imagefo'),
// Day 4 = Thursday 3 PM CET
show: isPromoLive('2025-05-22', '15:00')
},
{
id: 'modal:init3_file_tokens',
src: getSrc(Init3TokensPromo),
title: 'Announcing: File Tokens',
message:
'File tokens let you share files easily and securely, without modifying permissions or changing project access.',
plan: 'free',
importance: 8,
scope: 'everywhere',
cta: sharedCTA('https://apwr.dev/filet'),
// Day 5 = Friday 3 PM CET
show: isPromoLive('2025-05-23', '15:00')
}
];
const currentPromo = promos.find((promo) => promo.show);
if (currentPromo) {
listOfPromotions.push(currentPromo);
setMobileSingleAlertLayout({
title: currentPromo.title,
message: currentPromo.message,
enabled: true,
cta: currentPromo.cta
});
}
if (isCloud) {
/* add promos here */
}
export function addBottomModalAlerts() {
listOfPromotions.forEach((promotion) => showBottomModalAlert(promotion));
}
// use this for time based promo handling
// noinspection JSUnusedGlobalSymbols
export function isPromoLive(
date: string,
time: string,
@@ -11,7 +11,7 @@
import type { Coupon } from '$lib/sdk/billing';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { type OrganizationError, type Organization } from '$lib/stores/organization';
import type { OrganizationError, Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { ID } from '@appwrite.io/console';
@@ -35,11 +35,6 @@
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;
@@ -54,9 +49,9 @@
const coupon = page.url.searchParams.get('coupon');
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
selectedCoupon = response;
} catch (e) {
couponData = {
selectedCoupon = {
code: null,
status: null,
credits: null
@@ -127,7 +122,7 @@
selectedPlan,
paymentMethodId,
null,
couponData.code ? couponData.code : null,
selectedCoupon?.code,
collaborators,
billingBudget,
taxId
@@ -148,7 +143,7 @@
'',
clientSecret,
paymentMethodId,
'/console/create-organization?' + params.toString()
`/console/create-organization?${params}`
);
await validate(org.teamId, collaborators);
}
@@ -195,35 +190,47 @@
required />
</Fieldset>
<Fieldset legend="Select plan">
<Typography.Text>
For more details on our plans, visit our
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
</Typography.Text>
<PlanSelection
bind:billingPlan={selectedPlan}
anyOrgFree={data.hasFreeOrganizations}
isNewOrg />
<Layout.Stack>
<Typography.Text>
For more details on our plans, visit our
<Link.Anchor
href="https://appwrite.io/pricing"
target="_blank"
rel="noopener noreferrer">pricing page</Link.Anchor
>.
</Typography.Text>
<PlanSelection
bind:billingPlan={selectedPlan}
anyOrgFree={data.hasFreeOrganizations}
isNewOrg />
</Layout.Stack>
</Fieldset>
{#if selectedPlan !== BillingPlan.FREE}
<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>
<Layout.Stack gap="s" alignItems="flex-start">
<SelectPaymentMethod
methods={data.paymentMethods}
bind:value={paymentMethodId}
bind:taxId>
<svelte:fragment slot="actions">
{#if !selectedCoupon?.code && paymentMethodId}
<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>
{#if !selectedCoupon?.code && !paymentMethodId}
<Button compact on:click={() => (showCreditModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add credits
</Button>
{/if}
</Layout.Stack>
</Fieldset>
<Fieldset legend="Invite members">
<InputTags
@@ -240,7 +247,6 @@
<EstimatedTotalBox
billingPlan={selectedPlan}
{collaborators}
plans={data.plansInfo}
bind:couponData={selectedCoupon}
bind:billingBudget />
{:else}
@@ -1,10 +1,11 @@
import { BillingPlan } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
import type { Coupon } from '$lib/sdk/billing';
import type { Organization } from '$lib/stores/organization';
export const load: PageLoad = async ({ url, parent }) => {
export const load: PageLoad = async ({ url, parent, depends }) => {
depends(Dependencies.CREATE_ORGANIZATION);
const { organizations } = await parent();
const [coupon, paymentMethods] = await Promise.all([
getCoupon(url),
@@ -41,7 +42,7 @@ async function getCoupon(url: URL): Promise<Coupon | null> {
if (url.searchParams.has('code')) {
const coupon = url.searchParams.get('code');
try {
return sdk.forConsole.billing.getCoupon(coupon);
return sdk.forConsole.billing.getCouponAccount(coupon);
} catch (e) {
return null;
}
@@ -0,0 +1,115 @@
<script lang="ts">
import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { ID } from '@appwrite.io/console';
import { BillingPlan } from '$lib/constants';
import { tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { loadAvailableRegions } from '$routes/(console)/regions';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Button, Card, Layout, Input, Typography, Spinner } from '@appwrite.io/pink-svelte';
import { Form } from '$lib/elements/forms/index.js';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
let isLoading = false;
let organizationName = 'Personal Projects';
async function createOrganization() {
isLoading = true;
let organization = null;
try {
if (isCloud) {
organization = await sdk.forConsole.billing.createOrganization(
ID.unique(),
organizationName,
BillingPlan.FREE,
null,
null
);
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
budget_cap_enabled: false,
members_invited: 0
});
} else {
organization = await sdk.forConsole.teams.create(ID.unique(), organizationName);
}
} catch (e) {
trackError(e, Submit.OrganizationCreate);
addNotification({
type: 'error',
message: e.message
});
} finally {
isLoading = false;
if (organization) {
loadAvailableRegions(organization?.$id).then();
await goto(`${base}/organization-${organization.$id}`);
}
}
}
</script>
<svelte:head>
<title>Create organization - Appwrite</title>
</svelte:head>
<Form onSubmit={createOrganization}>
<div class="viewport-center">
<div class="page-container u-cross-child-center">
<Card.Base variant="primary" padding="l">
<Layout.Stack direction="column" gap="xxl" alignItems="flex-start">
<Typography.Title size="l">Create an organization</Typography.Title>
<Input.Text
required
disabled={isLoading}
label="Organization name"
bind:value={organizationName}
placeholder="Personal Projects" />
<Layout.Stack direction="row" justifyContent="flex-end">
<Button.Button
size="s"
autofocus
type="submit"
variant="primary"
disabled={isLoading}>
{#if isLoading}
<Spinner size="s" />
{/if}
Create
</Button.Button>
</Layout.Stack>
</Layout.Stack>
</Card.Base>
</div>
</div>
</Form>
<style lang="scss">
@use '@appwrite.io/pink-legacy/src/abstract/variables/devices';
:global(body) {
margin: 0;
background: var(--bgcolor-neutral-default, #19191c);
}
.viewport-center {
height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.page-container {
width: calc(100% - 2rem);
@media #{devices.$break2open} {
width: 700px;
}
}
</style>
@@ -10,14 +10,29 @@
import { base } from '$app/paths';
import { addNotification } from '$lib/stores/notifications';
import CreateProject from '$lib/layout/createProject.svelte';
import { loadAvailableRegions } from '$routes/(console)/regions';
import { regions as regionsStore } from '$lib/stores/organization';
import { user } from '$lib/stores/user';
let isLoading = false;
let id: string;
let startAnimation = false;
let projectName = 'Appwrite project';
let region = Region.Fra;
export let data;
function markOnboardingComplete() {
const currentPrefs = data.accountPrefs ?? $user.prefs;
const newPrefs = {
...currentPrefs,
newOnboardingCompleted: true
};
sdk.forConsole.account.updatePrefs(newPrefs);
}
async function createProject() {
isLoading = true;
@@ -29,6 +44,9 @@
teamId,
isCloud ? region : undefined
);
markOnboardingComplete();
trackEvent(Submit.ProjectCreate, {
customId: !!id,
teamId
@@ -49,6 +67,9 @@
});
}
}
// safe side!
loadAvailableRegions(data.organization?.$id);
</script>
<svelte:head>
@@ -74,7 +95,7 @@
alt="Appwrite Logo" />
<Card.Base variant="primary" padding="l">
<CreateProject
regions={isCloud ? data.regions.regions : []}
regions={$regionsStore?.regions}
bind:projectName
bind:id
bind:region
@@ -3,72 +3,84 @@ import { isCloud } from '$lib/system';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { ID, Query } from '@appwrite.io/console';
import { ID, Query, type Models } from '@appwrite.io/console';
import { BillingPlan } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
// TODO: this needs to be cleaned up!
export const load: PageLoad = async ({ parent }) => {
const { organizations } = await parent();
try {
if (!organizations?.total) {
try {
if (isCloud) {
const org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal projects',
BillingPlan.FREE,
null,
null
);
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
budget_cap_enabled: false,
members_invited: 0
});
const { account, organizations } = await parent();
if (isOrganization(org)) {
return {
organization: org,
regions: await sdk.forConsole.billing.listRegions(org.$id)
};
} else {
const e = new Error(org.message, {
cause: org
});
trackError(e, Submit.OrganizationCreate);
}
} else {
const accountPrefs = account.prefs;
const hasCompletedOnboarding = accountPrefs['newOnboardingCompleted'] ?? false;
// user has already seen it, lets redirect now!
if (hasCompletedOnboarding && !organizations?.total) {
redirect(303, `${base}/onboarding/create-organization`);
} else if (hasCompletedOnboarding && organizations?.total) {
redirect(303, `${base}/organization-${organizations.teams[0].$id}`);
}
if (!organizations?.total) {
try {
if (isCloud) {
const org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal projects',
BillingPlan.FREE,
null,
null
);
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
budget_cap_enabled: false,
members_invited: 0
});
if (isOrganization(org)) {
return {
organization: await sdk.forConsole.teams.create(
ID.unique(),
'Personal projects'
),
regions: null
accountPrefs,
organization: org
};
} else {
const e = new Error(org.message, {
cause: org
});
trackError(e, Submit.OrganizationCreate);
}
} catch (e) {
trackError(e, Submit.OrganizationCreate);
} else {
return {
accountPrefs,
organization: await sdk.forConsole.teams.create(
ID.unique(),
'Personal projects'
)
};
}
} else if (organizations?.total === 1) {
const org = organizations.teams[0];
const projects = await sdk.forConsole.projects.list([
} catch (e) {
trackError(e, Submit.OrganizationCreate);
}
} else if (organizations?.total === 1) {
const org = organizations.teams[0];
let projects: Models.ProjectList = null;
try {
projects = await sdk.forConsole.projects.list([
Query.equal('teamId', org.$id),
Query.limit(1)
]);
if (!projects.total) {
const regions = isCloud ? await sdk.forConsole.billing.listRegions(org.$id) : null;
return {
organization: org,
regions
};
} else {
redirect(303, `${base}/console/organization-${org.$id}`);
}
} else {
redirect(303, `${base}/console/organization-${organizations.teams[0].$id}`);
} catch (e) {
redirect(303, `${base}/organization-${org.$id}`);
}
} catch (error) {
console.error(error);
if (!projects?.total) {
return {
accountPrefs,
organization: org
};
} else {
redirect(303, `${base}/organization-${org.$id}`);
}
} else {
redirect(303, `${base}/organization-${organizations.teams[0].$id}`);
}
};
@@ -19,8 +19,7 @@
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { sdk } from '$lib/stores/sdk';
import { loading } from '$routes/store';
import type { Models } from '@appwrite.io/console';
import { ID, Region } from '@appwrite.io/console';
import { ID, Region, type Models } from '@appwrite.io/console';
import { openImportWizard } from '../project-[region]-[project]/settings/migrations/(import)';
import { readOnly } from '$lib/stores/billing';
import { onMount, type ComponentType } from 'svelte';
@@ -38,7 +37,7 @@
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { organization, regions as regionsStore } from '$lib/stores/organization';
import { regions as regionsStore } from '$lib/stores/organization';
export let data;
@@ -112,17 +111,11 @@
}
};
onMount(async () => {
if (isCloud && $organization.$id) {
const regions = await sdk.forConsole.billing.listRegions($organization.$id);
regionsStore.set(regions);
checkPricingRefAndRedirect(page.url.searchParams);
}
checkPricingRefAndRedirect(page.url.searchParams);
});
function findRegion(project: Models.Project) {
return $regionsStore?.regions?.find(
(region) => region.$id === (project as Models.Project & { region: string }).region
);
return $regionsStore.regions.find((region) => region.$id === project.region);
}
</script>
@@ -168,23 +161,28 @@
<svelte:fragment slot="title">
{project.name}
</svelte:fragment>
{#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 platforms.slice(0, 2) as platform}
{@const icon = getIconForPlatform(platform.icon)}
<Badge
variant="secondary"
content={platform.name}
style="width: max-content;">
<Icon {icon} size="s" slot="start" />
</Badge>
{/each}
{#if platforms?.length > 3}
<Badge variant="secondary" content={`+${project.platforms.length - 3}`} />
{#if platforms.length > 3}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
style="width: max-content;" />
{/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>
<Typography.Text>{region.name}</Typography.Text>
{/if}
</svelte:fragment>
</GridItem1>
@@ -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 { sdk } from '$lib/stores/sdk';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
@@ -27,7 +27,6 @@
IconExternalLink,
IconRefresh
} from '@appwrite.io/pink-icons-svelte';
import { base } from '$app/paths';
let offset = 0;
let invoiceList: InvoiceList = {
@@ -36,6 +35,7 @@
};
const limit = 5;
const endpoint = getApiEndpoint();
onMount(request);
@@ -134,12 +134,12 @@
<ActionMenu.Item.Anchor
leadingIcon={IconExternalLink}
external
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
View invoice
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Anchor
leadingIcon={IconDownload}
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
Download PDF
</ActionMenu.Item.Anchor>
{#if status === 'overdue' || status === 'failed'}

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