mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge remote-tracking branch 'origin/main' into 1.8.x
# Conflicts: # package.json # pnpm-lock.yaml # src/lib/sdk/billing.ts # src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/table.svelte
This commit is contained in:
+1
-1
@@ -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=
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 / {
|
||||
|
||||
+3
-2
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564",
|
||||
"@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/-/@appwrite/@appwrite.io/pink-svelte@063404f",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@40bae6b",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.38.0",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
@@ -65,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",
|
||||
|
||||
Generated
+44
-10
@@ -12,8 +12,8 @@ importers:
|
||||
specifier: ^1.1.24
|
||||
version: 1.1.24(svelte@5.25.3)(zod@3.24.3)
|
||||
'@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
|
||||
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564
|
||||
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564
|
||||
'@appwrite.io/pink-icons':
|
||||
specifier: 0.25.0
|
||||
version: 0.25.0
|
||||
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
'@appwrite.io/pink-svelte':
|
||||
specifier: https://try-module.cloud/-/@appwrite/@appwrite.io/pink-svelte@063404f
|
||||
version: https://try-module.cloud/-/@appwrite/%40appwrite.io%2Fpink-svelte@063404f(svelte@5.25.3)
|
||||
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@40bae6b
|
||||
version: https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@40bae6b(svelte@5.25.3)
|
||||
'@popperjs/core':
|
||||
specifier: ^2.11.8
|
||||
version: 2.11.8
|
||||
@@ -135,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
|
||||
@@ -254,8 +257,8 @@ packages:
|
||||
'@analytics/type-utils@0.6.2':
|
||||
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
|
||||
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19':
|
||||
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19}
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564':
|
||||
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564}
|
||||
version: 1.8.0
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1':
|
||||
@@ -278,8 +281,8 @@ 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/-/@appwrite/%40appwrite.io%2Fpink-svelte@063404f':
|
||||
resolution: {tarball: https://try-module.cloud/-/@appwrite/%40appwrite.io%2Fpink-svelte@063404f}
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@40bae6b':
|
||||
resolution: {tarball: https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@40bae6b}
|
||||
version: 2.0.0-RC.2
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0
|
||||
@@ -1714,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'}
|
||||
@@ -3616,7 +3635,7 @@ snapshots:
|
||||
|
||||
'@analytics/type-utils@0.6.2': {}
|
||||
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@e190a19': {}
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@a5e5564': {}
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)':
|
||||
dependencies:
|
||||
@@ -3635,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/-/@appwrite/%40appwrite.io%2Fpink-svelte@063404f(svelte@5.25.3)':
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/%40appwrite.io%2Fpink-svelte@40bae6b(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
|
||||
@@ -5256,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
|
||||
|
||||
Vendored
+12
@@ -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
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -167,13 +167,10 @@
|
||||
<div style="padding: 1rem; padding-block-end: 0;">
|
||||
<Alert.Inline
|
||||
dismissible
|
||||
title="We collect user responses to refine our experimental AI feature."
|
||||
on:dismiss={() => {
|
||||
$preferences.hideAiDisclaimer = true;
|
||||
}}>
|
||||
<span slot="title">
|
||||
We collect user responses to refine our experimental AI feature.
|
||||
</span>
|
||||
</Alert.Inline>
|
||||
}} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -229,8 +226,11 @@
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<AvatarInitials size="s" name={$user.name} />
|
||||
<form
|
||||
class="input-text-wrapper u-width-full-line"
|
||||
class="u-full-width input-text-wrapper"
|
||||
style="--amount-of-buttons: 1;"
|
||||
style:display="flex"
|
||||
style:width="100%"
|
||||
style:align-items="center"
|
||||
on:submit|preventDefault={(e) => {
|
||||
handleSubmit(e);
|
||||
}}>
|
||||
@@ -238,6 +238,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="input-text"
|
||||
style:width="100%"
|
||||
placeholder="Ask a question..."
|
||||
autofocus
|
||||
bind:value={$input}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)'}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
@@ -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>[];
|
||||
};
|
||||
|
||||
@@ -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'/>`
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
const userPreferences = () => get(user)?.prefs;
|
||||
|
||||
export const joinWaitlistSites = () => {
|
||||
const prefs = userPreferences();
|
||||
const newPrefs = {
|
||||
...prefs,
|
||||
joinWaitlistSites: true
|
||||
};
|
||||
|
||||
sdk.forConsole.account.updatePrefs(newPrefs);
|
||||
|
||||
if (sessionStorage) {
|
||||
sessionStorage.setItem('joinWaitlistSites', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
export const isOnWaitlistSites = (): boolean => {
|
||||
const prefs = userPreferences();
|
||||
const joinedInPrefs = 'joinWaitlistSites' in prefs;
|
||||
|
||||
let joinedInSession = false;
|
||||
if (sessionStorage) {
|
||||
joinedInSession = sessionStorage.getItem('joinWaitlistSites') === 'true';
|
||||
}
|
||||
|
||||
return joinedInSession || joinedInPrefs;
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { getFlagUrl } from '$lib/helpers/flag';
|
||||
import { isCloud } from '$lib/system.js';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { filterRegions } from '$lib/helpers/regions';
|
||||
|
||||
export let projectName: string;
|
||||
export let id: string;
|
||||
@@ -13,25 +14,6 @@
|
||||
export let showTitle = true;
|
||||
|
||||
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 || !region.available
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -67,9 +49,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
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
@@ -278,6 +278,7 @@ export type AddressesList = {
|
||||
};
|
||||
|
||||
export type AdditionalResource = {
|
||||
name: string;
|
||||
currency: string;
|
||||
invoiceDesc: string;
|
||||
price: number;
|
||||
@@ -309,6 +310,7 @@ export type Plan = {
|
||||
users: number;
|
||||
teams: number;
|
||||
databases: number;
|
||||
databasesAllowEncrypt: boolean;
|
||||
buckets: number;
|
||||
fileSize: number;
|
||||
functions: number;
|
||||
@@ -338,7 +340,7 @@ export type Plan = {
|
||||
backupPolicies: number;
|
||||
emailBranding: boolean;
|
||||
supportsCredits: boolean;
|
||||
databasesAllowEncrypt: boolean;
|
||||
supportsOrganizationRoles: boolean;
|
||||
buildSize: number; // in MB
|
||||
deploymentSize: number; // in MB
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,4 +61,4 @@ export const organization = derived(page, ($page) => $page.data?.organization as
|
||||
export const currentPlan = derived(page, ($page) => $page.data?.currentPlan as Plan);
|
||||
export const members = derived(page, ($page) => $page.data.members as Models.MembershipList);
|
||||
|
||||
export const regions = writable<Models.ConsoleRegionList | undefined>(undefined);
|
||||
export const regions = writable<Models.ConsoleRegionList>({ total: 0, regions: [] });
|
||||
|
||||
@@ -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
@@ -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
@@ -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)',
|
||||
|
||||
@@ -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>
|
||||
>;
|
||||
|
||||
@@ -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="Couldn’t 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>
|
||||
|
||||
@@ -52,6 +52,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
|
||||
@@ -62,17 +64,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`)
|
||||
@@ -338,10 +341,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" />
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
...permanentTabs,
|
||||
{
|
||||
href: `${path}/payments`,
|
||||
title: 'Payment details',
|
||||
title: 'Payments',
|
||||
event: 'payments',
|
||||
hasChildren: true
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -12,14 +12,27 @@
|
||||
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;
|
||||
|
||||
@@ -31,6 +44,9 @@
|
||||
teamId,
|
||||
isCloud ? region : undefined
|
||||
);
|
||||
|
||||
markOnboardingComplete();
|
||||
|
||||
trackEvent(Submit.ProjectCreate, {
|
||||
customId: !!id,
|
||||
teamId
|
||||
@@ -53,7 +69,7 @@
|
||||
}
|
||||
|
||||
// safe side!
|
||||
loadAvailableRegions(data.organization.$id);
|
||||
loadAvailableRegions(data.organization?.$id);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -79,7 +95,7 @@
|
||||
alt="Appwrite Logo" />
|
||||
<Card.Base variant="primary" padding="l">
|
||||
<CreateProject
|
||||
regions={$regionsStore.regions}
|
||||
regions={$regionsStore?.regions}
|
||||
bind:projectName
|
||||
bind:id
|
||||
bind:region
|
||||
|
||||
@@ -3,70 +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();
|
||||
const { account, 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 accountPrefs = account.prefs;
|
||||
const hasCompletedOnboarding = accountPrefs['newOnboardingCompleted'] ?? false;
|
||||
|
||||
if (isOrganization(org)) {
|
||||
return {
|
||||
organization: org
|
||||
};
|
||||
} else {
|
||||
const e = new Error(org.message, {
|
||||
cause: org
|
||||
});
|
||||
trackError(e, Submit.OrganizationCreate);
|
||||
}
|
||||
} else {
|
||||
// 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'
|
||||
)
|
||||
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) {
|
||||
return {
|
||||
organization: org
|
||||
};
|
||||
} 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,15 +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.region);
|
||||
return $regionsStore.regions.find((region) => region.$id === project.region);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -166,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'}
|
||||
|
||||
@@ -99,6 +99,12 @@
|
||||
$: if (!showReplace) {
|
||||
isSelectedBackup = false;
|
||||
}
|
||||
|
||||
$: hasPaymentError =
|
||||
defaultPaymentMethod?.lastError ||
|
||||
defaultPaymentMethod?.expired ||
|
||||
backupPaymentMethod?.lastError ||
|
||||
backupPaymentMethod?.expired;
|
||||
</script>
|
||||
|
||||
<CardGrid>
|
||||
@@ -109,10 +115,10 @@
|
||||
<Table.Root
|
||||
let:root
|
||||
columns={[
|
||||
{ id: 'cc' },
|
||||
{ id: 'name' },
|
||||
{ id: 'expiry' },
|
||||
{ id: 'status' },
|
||||
{ id: 'cc', width: 140 },
|
||||
{ id: 'name', width: { min: 140 } },
|
||||
{ id: 'expiry', width: 100 },
|
||||
{ id: 'status', width: 110, hide: !hasPaymentError },
|
||||
{ id: 'actions', width: 40 }
|
||||
]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import type { Aggregation, CreditList, Invoice, Plan } from '$lib/sdk/billing';
|
||||
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
import { humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import {
|
||||
@@ -35,21 +34,6 @@
|
||||
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
|
||||
$plansInfo.get($organization.billingPlan)?.trialDays;
|
||||
const extraUsage = currentInvoice ? currentInvoice.amount - currentPlan?.price : 0;
|
||||
|
||||
function usageNameToLabel(name: string): string {
|
||||
switch (name) {
|
||||
case 'executions':
|
||||
return 'Executions';
|
||||
case 'storage':
|
||||
return 'Storage';
|
||||
case 'users':
|
||||
return 'Users';
|
||||
case 'bandwidth':
|
||||
return 'Bandwidth';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $organization}
|
||||
@@ -112,69 +96,29 @@
|
||||
{#if i > 0 || currentAggregation.additionalMembers}
|
||||
<Divider />
|
||||
{/if}
|
||||
{#if ['storage', 'bandwidth'].includes(excess.name)}
|
||||
{@const excessValue = humanFileSize(excess.value)}
|
||||
<Layout.Stack gap="xxxs">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="space-between">
|
||||
<Typography.Text
|
||||
color="--fgcolor-neutral-primary"
|
||||
>{usageNameToLabel(
|
||||
excess.name
|
||||
)}</Typography.Text>
|
||||
<Typography.Text
|
||||
>{formatCurrency(
|
||||
excess.amount
|
||||
)}</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row">
|
||||
<Layout.Stack direction="row">
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
disabled={excess.value <=
|
||||
1_000_000_000}>
|
||||
<svelte:fragment slot="tooltip">
|
||||
{formatNumberWithCommas(
|
||||
excess.value ?? 0
|
||||
)} bytes
|
||||
</svelte:fragment>
|
||||
<span
|
||||
>{excessValue.value ??
|
||||
0}{excessValue.unit}</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
|
||||
<Layout.Stack gap="xxxs">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="space-between">
|
||||
<Typography.Text color="--fgcolor-neutral-primary">
|
||||
{excess.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
{formatCurrency(excess.amount)}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{#if ['users', 'executions'].includes(excess.name)}
|
||||
<Layout.Stack gap="xxxs">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="space-between">
|
||||
<Typography.Text
|
||||
color="--fgcolor-neutral-primary"
|
||||
>{usageNameToLabel(
|
||||
excess.name
|
||||
)}</Typography.Text>
|
||||
<Typography.Text
|
||||
>{formatCurrency(
|
||||
excess.amount
|
||||
)}</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row">
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
disabled={excess.value <= 1000}>
|
||||
<svelte:fragment slot="tooltip">
|
||||
{formatNumberWithCommas(excess.value)}
|
||||
</svelte:fragment>
|
||||
<span
|
||||
>{abbreviateNumber(excess.value)}</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row">
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
disabled={excess.value <= 1000}>
|
||||
<svelte:fragment slot="tooltip">
|
||||
{formatNumberWithCommas(excess.value)}
|
||||
</svelte:fragment>
|
||||
<span>{abbreviateNumber(excess.value)}</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { loadAvailableRegions } from '$routes/(console)/regions';
|
||||
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
|
||||
|
||||
export let data;
|
||||
@@ -54,18 +55,18 @@
|
||||
let showCreditModal = false;
|
||||
let feedbackDowngradeReason: string;
|
||||
let feedbackMessage: string;
|
||||
let couponData: Partial<Coupon> = data.coupon;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (page.url.searchParams.has('code')) {
|
||||
const coupon = page.url.searchParams.get('code');
|
||||
try {
|
||||
couponData = await sdk.forConsole.billing.getCouponAccount(coupon);
|
||||
selectedCoupon = await sdk.forConsole.billing.getCouponAccount(coupon);
|
||||
} catch (e) {
|
||||
couponData = {
|
||||
selectedCoupon = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
@@ -191,7 +192,7 @@
|
||||
selectedPlan,
|
||||
paymentMethodId,
|
||||
null,
|
||||
couponData?.code,
|
||||
selectedCoupon?.code,
|
||||
newCollaborators,
|
||||
billingBudget,
|
||||
taxId ? taxId : null
|
||||
@@ -219,6 +220,11 @@
|
||||
}
|
||||
|
||||
if (isOrganization(org)) {
|
||||
/**
|
||||
* Reload on upgrade (e.g. Free → Paid)
|
||||
*/
|
||||
loadAvailableRegions(org.$id, true);
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
@@ -303,8 +309,7 @@
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
|
||||
<!-- Show email input if upgrading from free plan -->
|
||||
{#if selectedPlan !== BillingPlan.FREE && data.organization.billingPlan === BillingPlan.FREE}
|
||||
{#if isUpgrade}
|
||||
<Fieldset legend="Payment">
|
||||
<SelectPaymentMethod
|
||||
methods={data.paymentMethods}
|
||||
@@ -355,10 +360,10 @@
|
||||
<EstimatedTotalBox
|
||||
{collaborators}
|
||||
{isDowngrade}
|
||||
plans={data.plansInfo}
|
||||
billingPlan={selectedPlan}
|
||||
bind:couponData={selectedCoupon}
|
||||
bind:billingBudget />
|
||||
bind:billingBudget
|
||||
organizationId={data.organization.$id} />
|
||||
{:else if data.organization.billingPlan !== BillingPlan.CUSTOM}
|
||||
<PlanComparisonBox downgrade={isDowngrade} />
|
||||
{/if}
|
||||
|
||||
@@ -49,7 +49,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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user