Merge branch 'feat-pink-v2' into 'file-tokens'.

This commit is contained in:
Darshan
2025-05-08 09:27:19 +05:30
200 changed files with 3278 additions and 1427 deletions
+1
View File
@@ -42,6 +42,7 @@ jobs:
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
"SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}"
"SENTRY_RELEASE=${{ github.event.release.tag_name }}"
publish-cloud-stage:
runs-on: ubuntu-latest
steps:
+4 -1
View File
@@ -6,6 +6,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm i -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.0.0 --activate
ADD ./package.json /app/package.json
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml
@@ -24,12 +25,14 @@ ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
ARG PUBLIC_STRIPE_KEY
ARG SENTRY_AUTH_TOKEN
ARG SENTRY_RELEASE
ENV PUBLIC_APPWRITE_ENDPOINT=$PUBLIC_APPWRITE_ENDPOINT
ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV SENTRY_RELEASE=$SENTRY_RELEASE
ENV NODE_OPTIONS=--max_old_space_size=8192
RUN pnpm run build
@@ -39,4 +42,4 @@ FROM nginx:1.25-alpine
EXPOSE 80
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html/console
COPY --from=build /app/build /usr/share/nginx/html/console
+5 -3
View File
@@ -21,11 +21,13 @@
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0",
"@ai-sdk/svelte": "^1.1.22",
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
+1 -1
View File
@@ -13,7 +13,7 @@ const config: PlaywrightTestConfig = {
webServer: {
timeout: 120000,
env: {
PUBLIC_APPWRITE_ENDPOINT: 'https://console-testing-2.appwrite.org/v1',
PUBLIC_APPWRITE_ENDPOINT: 'https://stage.cloud.appwrite.io/v1',
PUBLIC_CONSOLE_MODE: 'cloud',
PUBLIC_STRIPE_KEY:
'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn'
+130 -19
View File
@@ -8,21 +8,27 @@ importers:
.:
dependencies:
'@ai-sdk/svelte':
specifier: ^1.1.22
version: 1.1.22(svelte@5.25.3)(zod@3.24.3)
'@appwrite.io/console':
specifier: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0
version: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0
specifier: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1
version: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1
'@appwrite.io/pink':
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons-svelte':
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5(svelte@5.25.3)
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f(svelte@5.25.3)
'@appwrite.io/pink-legacy':
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -192,6 +198,37 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
'@ai-sdk/provider-utils@2.1.11':
resolution: {integrity: sha512-lMnXA5KaRJidzW7gQmlo/SnX6D+AKk5GxHFcQtOaGOSJNmu/qcNZc1rGaO7K5qW52OvCLXtnWudR4cc/FvMpVQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/provider@1.0.10':
resolution: {integrity: sha512-pco8Zl9U0xwXI+nCLc0woMtxbvjU8hRmGTseAUiPHFLYAAL8trRPCukg69IDeinOvIeo1SmXxAIdWWPZOLb4Cg==}
engines: {node: '>=18'}
'@ai-sdk/svelte@1.1.22':
resolution: {integrity: sha512-xtJjvEPHD8GDB1iODvsRLKplZ9CVUN/gT4U+nttuEvjV42qoPXKkrWwjTLEdWKnwTnoHuuI8mnQMje6RhFWtpA==}
engines: {node: '>=18'}
peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
peerDependenciesMeta:
svelte:
optional: true
'@ai-sdk/ui-utils@1.1.17':
resolution: {integrity: sha512-fCnp/wntZGqPf6tiCmhuQoSDLSBhXoI5DU2JX4As96EO870+jliE6ozvYUwYOZC6Ta2OKAjjWPcSP7HeHX0b+g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
peerDependenciesMeta:
zod:
optional: true
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@@ -217,18 +254,18 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0':
resolution: {tarball: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1':
resolution: {tarball: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1}
version: 1.2.1
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5874e143bc11356abea82e2e964e391ce8':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5874e143bc11356abea82e2e964e391ce8}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
@@ -242,13 +279,16 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653}
version: 1.0.0-next.85
peerDependencies:
react-dom: ^18.0.0
svelte: ^4.0.0
'@appwrite.io/pink@0.25.0':
resolution: {integrity: sha512-9z4G/iAhjvVpM7tkNqzGqrokst4hvNt3Fkf2jD4W6n8j//EJbQ3mJ0I+5dye5Sry0a33wUanq5DSztqFNYq5zw==}
'@asamuzakjp/css-color@3.1.1':
resolution: {integrity: sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA==}
@@ -2071,6 +2111,10 @@ packages:
resolution: {integrity: sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g==}
engines: {node: '>=14.18'}
eventsource-parser@3.0.0:
resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==}
engines: {node: '>=18.0.0'}
expect-type@1.2.0:
resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==}
engines: {node: '>=12.0.0'}
@@ -2421,6 +2465,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -2616,6 +2663,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize.css@8.0.1:
resolution: {integrity: sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==}
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
@@ -2923,6 +2973,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -3485,6 +3538,14 @@ packages:
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
peerDependencies:
zod: ^3.24.1
zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
@@ -3495,6 +3556,36 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
'@ai-sdk/provider-utils@2.1.11(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.0.10
eventsource-parser: 3.0.0
nanoid: 3.3.11
secure-json-parse: 2.7.0
optionalDependencies:
zod: 3.24.3
'@ai-sdk/provider@1.0.10':
dependencies:
json-schema: 0.4.0
'@ai-sdk/svelte@1.1.22(svelte@5.25.3)(zod@3.24.3)':
dependencies:
'@ai-sdk/provider-utils': 2.1.11(zod@3.24.3)
'@ai-sdk/ui-utils': 1.1.17(zod@3.24.3)
optionalDependencies:
svelte: 5.25.3
transitivePeerDependencies:
- zod
'@ai-sdk/ui-utils@1.1.17(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.0.10
'@ai-sdk/provider-utils': 2.1.11(zod@3.24.3)
zod-to-json-schema: 3.24.5(zod@3.24.3)
optionalDependencies:
zod: 3.24.3
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
@@ -3534,13 +3625,13 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@6dbeff0': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1': {}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5874e143bc11356abea82e2e964e391ce8(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3553,9 +3644,9 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@c339db5(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@c339db5874e143bc11356abea82e2e964e391ce8(svelte@5.25.3)
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
'@melt-ui/pp': 0.3.2(@melt-ui/svelte@0.86.6(svelte@5.25.3))(svelte@5.25.3)
'@melt-ui/svelte': 0.86.6(svelte@5.25.3)
@@ -3569,6 +3660,12 @@ snapshots:
svelte-motion: 0.12.2(svelte@5.25.3)
svelte-sonner: 0.3.28(svelte@5.25.3)
'@appwrite.io/pink@0.25.0':
dependencies:
'@appwrite.io/pink-icons': 0.25.0
normalize.css: 8.0.1
the-new-css-reset: 1.11.3
'@asamuzakjp/css-color@3.1.1':
dependencies:
'@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)
@@ -5634,6 +5731,8 @@ snapshots:
eventsource-parser@1.0.0: {}
eventsource-parser@3.0.0: {}
expect-type@1.2.0: {}
fast-deep-equal@3.1.3: {}
@@ -5996,6 +6095,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
@@ -6154,6 +6255,8 @@ snapshots:
normalize-path@3.0.0: {}
normalize.css@8.0.1: {}
nwsapi@2.2.20: {}
object-inspect@1.13.4: {}
@@ -6468,6 +6571,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
secure-json-parse@2.7.0: {}
semver@6.3.1: {}
semver@7.7.1: {}
@@ -7023,6 +7128,12 @@ snapshots:
zimmerframe@1.1.2: {}
zod-to-json-schema@3.24.5(zod@3.24.3):
dependencies:
zod: 3.24.3
zod@3.24.3: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
+3
View File
@@ -6,6 +6,7 @@ import { user } from '$lib/stores/user';
import { ENV, MODE, VARS, isCloud } from '$lib/system';
import { AppwriteException } from '@appwrite.io/console';
import { browser } from '$app/environment';
import { getReferrerAndUtmSource } from '$lib/helpers/utm';
function plausible(domain: string): AnalyticsPlugin {
if (!browser) return { name: 'analytics-plugin-plausible' };
@@ -64,6 +65,8 @@ export function trackEvent(name: string, data: object = null): void {
};
}
data = { ...data, ...getReferrerAndUtmSource() };
if (ENV.DEV || ENV.PREVIEW) {
console.debug(`[Analytics] Event ${name} ${path}`, data);
} else {
+9 -2
View File
@@ -8,16 +8,23 @@
<script lang="ts">
import { Colors } from '$lib/charts/config';
import { Status } from '$lib/components';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { abbreviateNumber, formatNumberWithCommas } from '$lib/helpers/numbers';
export let legendData: LegendData[] = [];
export let decimalsForAbbreviate: number = 1;
export let numberFormat: 'comma' | 'abbreviate' = 'comma';
let colors = Object.values(Colors);
</script>
<div class="u-flex u-cross-center u-gap-16">
{#each legendData as { name, value }, index}
{@const formattedValue = typeof value === 'number' ? formatNumberWithCommas(value) : value}
{@const formattedValue =
typeof value === 'number'
? numberFormat === 'comma'
? formatNumberWithCommas(value)
: abbreviateNumber(value, decimalsForAbbreviate)
: value}
<Status status="none" statusIconStyle="background-color: {colors[index % colors.length]}">
{name} ({formattedValue})
</Status>
+4 -3
View File
@@ -7,7 +7,7 @@
import { AvatarInitials, Code, LoadingDots, SvgIcon } from '$lib/components';
import { user } from '$lib/stores/user';
import { useCompletion } from 'ai/svelte';
import { useCompletion } from '@ai-sdk/svelte';
import { subPanels } from '../subPanels';
import { isLanguage, type Language } from '$lib/components/code.svelte';
@@ -20,7 +20,8 @@
headers: {
'x-appwrite-project': 'console'
},
credentials: 'include'
credentials: 'include',
streamProtocol: 'text'
});
const examples = [
@@ -179,7 +180,7 @@
{#if $isLoading || answer}
<div class="content">
<div class="u-flex u-gap-8 u-cross-center">
<div class="avatar is-size-x-small">{getInitials($user.name)}</div>
<div class="avatar is-size-x-small">{getInitials($user.name || $user.email)}</div>
<p class="u-opacity-75">{previousQuestion}</p>
</div>
<div class="u-flex u-gap-8 u-margin-block-start-24">
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Click, trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { activeHeaderAlert } from '$routes/(console)/store';
@@ -29,7 +29,7 @@
secondary
fullWidthMobile
class="u-line-height-1"
href={`${base}/apply-credit?code=appw50&org=${$organization.$id}`}
href={`${base}/apply-credit?code=${NEW_DEV_PRO_UPGRADE_COUPON}&org=${$organization.$id}`}
on:click={() => {
trackEvent(Click.CreditsRedeemClick, {
from: 'button',
@@ -0,0 +1,25 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { failedInvoice } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
$: isOnProjects = $page.route.id.includes('project-[project]');
</script>
{#if $failedInvoice && $failedInvoice.teamId === $organization.$id && isOnProjects}
<HeaderAlert type="error" title="A scheduled payment for {$organization.name} failed">
To avoid service disruptions in your projects, please verify your payment details and try
again.
<svelte:fragment slot="buttons">
<Button
href={`${base}/organization-${$organization?.$id}/billing`}
secondary
fullWidthMobile>
<span class="text">Go to billing</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -18,7 +18,7 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
dispatch('validation', couponData);
coupon = null;
@@ -0,0 +1,49 @@
<script lang="ts">
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { IconX } from '@appwrite.io/pink-icons-svelte';
import { Icon } from '@appwrite.io/pink-svelte';
export let label: string;
export let value: number;
export let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
export let fixedCoupon = false;
</script>
{#if value > 0}
<span class="u-flex u-main-space-between">
<div class="u-flex u-cross-center u-gap-4">
<p class="text">
<span class="icon-tag u-color-text-success" aria-hidden="true"></span>
<span>
{label}
</span>
</p>
{#if !fixedCoupon && label.toLowerCase() === 'credits'}
<button
type="button"
class="button is-text is-only-icon"
style="--button-size:1.5rem;"
aria-label="Close"
title="Close"
on:click={() =>
(couponData = {
code: null,
status: null,
credits: null
})}>
<Icon icon={IconX} />
</button>
{/if}
</div>
{#if value >= 100}
<p class="inline-tag">Credits applied</p>
{:else}
<span class="u-color-text-success">-{formatCurrency(value)}</span>
{/if}
</span>
{/if}
@@ -0,0 +1,144 @@
<script lang="ts">
import { InputChoice, InputNumber } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon, Estimation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { AppwriteException } from '@appwrite.io/console';
import Card from '../card.svelte';
import DiscountsApplied from './discountsApplied.svelte';
import { addNotification } from '$lib/stores/notifications';
export let organizationId: string | undefined = undefined;
export let billingPlan: string;
export let collaborators: string[];
export let fixedCoupon = false;
export let couponData: Partial<Coupon>;
export let billingBudget: number;
let budgetEnabled = false;
let estimation: Estimation;
async function getEstimate(
billingPlan: string,
collaborators: string[],
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationCreateOrganization(
billingPlan,
couponId === '' ? null : couponId,
collaborators ?? []
);
} catch (e) {
if (e instanceof AppwriteException) {
if (
e.type === 'billing_coupon_not_found' ||
e.type === 'billing_coupon_already_used' ||
e.type === 'billing_credit_unsupported'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
addNotification({
type: 'error',
isHtml: false,
message: e.message
});
}
}
async function getUpdatePlanEstimate(
organizationId: string,
billingPlan: string,
collaborators: string[],
couponId: string | undefined
) {
try {
estimation = await sdk.forConsole.billing.estimationUpdatePlan(
organizationId,
billingPlan,
couponId && couponId.length > 0 ? couponId : null,
collaborators ?? []
);
} catch (e) {
if (e instanceof AppwriteException) {
if (
e.type === 'billing_coupon_not_found' ||
e.type === 'billing_coupon_already_used' ||
e.type === 'billing_credit_unsupported'
) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
addNotification({
type: 'error',
isHtml: false,
message: e.message
});
}
}
$: organizationId
? getUpdatePlanEstimate(organizationId, billingPlan, collaborators, couponData?.code)
: getEstimate(billingPlan, collaborators, couponData?.code);
</script>
{#if estimation}
<Card class="u-flex u-flex-vertical u-gap-8">
<slot />
{#if estimation}
{#each estimation.items ?? [] as item}
{#if item.value > 0}
<span class="u-flex u-main-space-between">
<p class="text">{item.label}</p>
<p class="text">{formatCurrency(item.value)}</p>
</span>
{/if}
{/each}
{#each estimation.discounts ?? [] as item}
<DiscountsApplied {fixedCoupon} bind:couponData {...item} />
{/each}
<div class="u-sep-block-start"></div>
<span class="u-flex u-main-space-between">
<p class="text">Total due</p>
<p class="text">
{formatCurrency(estimation.grossAmount)}
</p>
</span>
<p class="text u-margin-block-start-16">
You'll pay <span class="u-bold">{formatCurrency(estimation.grossAmount)}</span> now.
{#if couponData?.code}Once your credits run out,{:else}Then{/if} you'll be charged
<span class="u-bold">{formatCurrency(estimation.amount)}</span> every 30 days.
</p>
{/if}
<InputChoice
type="switchbox"
id="budget"
label="Enable budget cap"
tooltip="If enabled, you will be notified when your spending reaches 75% of the set cap. Update cap alerts in your organization settings."
fullWidth
bind:value={budgetEnabled}>
{#if budgetEnabled}
<div class="u-margin-block-start-16">
<InputNumber
id="budget"
label="Budget cap (USD)"
placeholder="0"
min={0}
bind:value={billingBudget} />
</div>
{/if}
</InputChoice>
</Card>
{/if}
@@ -43,7 +43,7 @@
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>Additional seats ({collaborators?.length})</Typography.Text>
>Additional seats ({collaborators?.length ?? 0})</Typography.Text>
<Typography.Text variant={isDowngrade ? 'm-500' : 'm-400'}
>{formatCurrency(extraSeatsCost)}</Typography.Text>
</Layout.Stack>
+2 -1
View File
@@ -2,8 +2,9 @@ export { default as PaymentBoxes } from './paymentBoxes.svelte';
export { default as CouponInput } from './couponInput.svelte';
export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
export { default as UsageRates } from './usageRates.svelte';
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
export { default as CreditsApplied } from './creditsApplied.svelte';
export { default as PlanSelection } from './planSelection.svelte';
export { default as EstimatedTotal } from './estimatedTotal.svelte';
export { default as SelectPlan } from './selectPlan.svelte';
+14 -23
View File
@@ -2,19 +2,17 @@
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { Button } from '$lib/elements/forms';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import type { OrganizationUsage } from '$lib/sdk/billing';
import type { Aggregation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
export let tier: Tier;
export let members: number;
const plan = $plansInfo?.get(tier);
let excess: {
@@ -24,16 +22,15 @@
executions?: number;
members?: number;
} = null;
let usage: OrganizationUsage = null;
let aggregation: Aggregation = null;
let showExcess = false;
onMount(async () => {
usage = await sdk.forConsole.billing.listUsage(
aggregation = await sdk.forConsole.billing.getAggregation(
$organization.$id,
$organization.billingCurrentInvoiceDate,
new Date().toISOString()
$organization.billingAggregationId
);
excess = calculateExcess(usage, plan, members);
excess = calculateExcess(aggregation, plan);
showExcess = Object.values(excess).some((value) => value > 0);
});
</script>
@@ -41,22 +38,16 @@
{#if showExcess}
<Alert.Inline
status="error"
title={`Your ${tierToPlan($organization.billingPlan).name} plan subscription will end on ${toLocaleDate(
title={` Your organization will switch to ${tierToPlan(BillingPlan.FREE).name} plan on ${toLocaleDate(
$organization.billingNextInvoiceDate
)}`}>
Following payment of your final invoice, your organization will switch to the {tierToPlan(
BillingPlan.FREE
).name} plan. {#if excess?.members > 0}All team members except the owner will be removed on
that date.{/if} Service disruptions may occur unless resource usage is reduced.
<!-- Any executions, bandwidth, or messaging usage will be reset at that time. -->
<svelte:fragment slot="buttons">
<Button
text
external
href="https://appwrite.io/docs/advanced/platform/free#reaching-resource-limits">
Learn more
</Button>
</svelte:fragment>
You will retain access to {tierToPlan($organization.billingPlan).name} plan features until your
billing period ends. After that,
{#if excess?.members > 0}<span class="u-bold">
all team members except the owner will be removed,</span>
{:else}
<span class="u-bold">your organization will be limited to Free plan resources,</span>
{/if} and service disruptions may occur if usage exceeds Free plan limits.
</Alert.Inline>
<Table.Root columns={3} let:root>
@@ -73,7 +64,7 @@
{#if excess?.members}
<Table.Row.Base {root}>
<Table.Cell {root}>Organization members</Table.Cell>
<Table.Cell {root}>{plan.members} members</Table.Cell>
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up"></span>
@@ -0,0 +1,51 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { LabelCard } from '..';
export let billingPlan: string;
export let anyOrgFree = false;
export let isNewOrg = false;
let classes: string = '';
export { classes as class };
</script>
{#if billingPlan}
<ul class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8 {classes}">
{#each $plansInfo.values() as plan}
<li>
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={(plan.$id === BillingPlan.FREE && anyOrgFree) || !plan.selfService}
value={plan.$id}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
tooltipText={plan.$id === BillingPlan.FREE
? 'You are limited to 1 Free organization per account.'
: ''}
padding={1.5}>
<svelte:fragment slot="custom" let:disabled>
<div
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
class:u-opacity-50={disabled}>
<h4 class="body-text-2 u-bold">
{plan.name}
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
<span class="inline-tag">Current plan</span>
{/if}
</h4>
<p class="u-color-text-offline u-small">
{plan.desc}
</p>
<p>
{formatCurrency(plan?.price ?? 0)}
</p>
</div>
</svelte:fragment>
</LabelCard>
</li>
{/each}
</ul>
{/if}
+9 -2
View File
@@ -43,6 +43,13 @@
];
$: isFree = org.billingPlan === BillingPlan.FREE;
// equal or above means unlimited!
$: getCorrectSeatsCountValue = (count: number): string | number => {
// php int max is always larger than js
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
return exceedsSafeLimit ? 'Unlimited' : count || 0;
};
</script>
<Modal bind:show title="Usage rates">
@@ -75,10 +82,10 @@
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>{usage.resource}</Table.Cell>
<Table.Cell column="limit" {root}>
{plan[usage.id] || 'Unlimited'}
{getCorrectSeatsCountValue(plan.addons.seats.limit)}
</Table.Cell>
<Table.Cell column="rate" {root}>
{formatCurrency(plan.addons?.member?.price)}/{usage?.unit}
{formatCurrency(plan.addons?.seats?.price)}/{usage?.unit}
</Table.Cell>
</Table.Row.Base>
{:else}
@@ -19,7 +19,8 @@
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
// const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon); //TODO: double check that this is the correct method
if (response.onlyNewOrgs && !isNewOrg) {
show = false;
@@ -37,6 +38,14 @@
message: 'Credits applied successfully'
});
}
couponData = response;
dispatch('validation', couponData);
coupon = null;
show = false;
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
} catch (e) {
error = e.message;
}
+1 -1
View File
@@ -4,7 +4,7 @@
</script>
<Box>
<Layout.Stack direction="row" justifyContent="flex-start">
<Layout.Stack direction="row" justifyContent="flex-start" alignItems="center">
<slot name="image" />
<div>
<slot name="title" />
@@ -7,13 +7,10 @@
export let noAspectRatio = false;
</script>
<Card.Base variant="secondary" padding="s">
<Layout.Stack direction="row">
<Card.Base variant="secondary" padding="s" radius="s">
<Layout.Stack direction="row" gap="l">
{#if $$slots?.image}
<div
style:--p-file-preview-border-color="transparent"
class="file-preview is-full-cover-image u-width-full-line u-height-100-percent"
style={noAspectRatio ? 'aspect-ratio: auto' : ''}>
<div style="flex-shrink:0">
<slot name="image" />
</div>
{/if}
+6 -2
View File
@@ -11,6 +11,7 @@
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/state';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '$routes/(console)/project-[project]/store';
$: $selectedFeedback = feedbackOptions.find((option) => option.type === $feedback.type);
@@ -24,8 +25,11 @@
page.url.href,
$user.name,
$user.email,
$organization.billingPlan,
$feedbackData.value
$organization?.billingPlan,
$feedbackData.value,
$organization?.$id,
$project?.$id,
$user.$id
);
addNotification({
type: 'success',
@@ -12,7 +12,9 @@
<ActionMenu.Item.Button
on:click={() => {
show = true;
}}>Custom filters</ActionMenu.Item.Button>
}}>
Custom filters
</ActionMenu.Item.Button>
</ActionMenu.Root>
{#if show}
+20 -4
View File
@@ -11,11 +11,19 @@
} from '$lib/elements/forms';
import type { Writable } from 'svelte/store';
import Modal from '../modal.svelte';
import { addFilter, generateTag, operators, queries, type TagValue } from './store';
import {
addFilter,
generateTag,
operators,
queries,
ValidOperators,
type TagValue
} from './store';
import type { Column } from '$lib/helpers/types';
import { TagList } from '.';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { addFilterAndApply } from './quickFilters';
export let show = false;
export let columns: Writable<Column[]>;
@@ -35,9 +43,17 @@
function apply() {
localQueries.forEach((query) => {
addFilter($columns, query.id, query.operator, query.value, query.arrayValues);
addFilterAndApply(
query.id,
$columns.find((c) => c.id === query.id).title,
query.operator as ValidOperators,
query.value,
query.arrayValues,
$columns,
analyticsSource
);
});
queries.apply();
localTags = [];
localQueries = [];
trackEvent(Submit.FilterApply, {
@@ -48,7 +64,7 @@
}
function addCondition() {
const newTag = generateTag(selectedColumn, operatorKey, value, arrayValues);
const newTag = generateTag(selectedColumn, operatorKey, value || arrayValues);
if (localTags.some((t) => t.tag === newTag.tag && t.value === newTag.value)) {
return;
} else {
+56 -93
View File
@@ -1,107 +1,70 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { queryParamToMap } from '$lib/components/filters/store';
import type { Column } from '$lib/helpers/types';
import { type Writable } from 'svelte/store';
import { CustomFilters, FiltersBottomSheet } from '$lib/components/filters';
import { addFilterAndApply, buildFilterCol } from './quickFilters';
import { parsedTags, setFilters } from './setFilters';
import { addFilterAndApply, type FilterData } from './quickFilters';
import { parsedTags } from './setFilters';
import Menu from '../menu/menu.svelte';
import { Button } from '$lib/elements/forms';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconFilterLine } from '@appwrite.io/pink-icons-svelte';
import QuickfiltersSubMenu from './quickfiltersSubMenu.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
export let columns: Writable<Column[]>;
export let analyticsSource: string;
export let openBottomSheet = false;
let {
columns,
filterCols,
analyticsSource
}: {
columns: Writable<Column[]>;
filterCols: FilterData[];
analyticsSource?: string;
} = $props();
//TODO: remove this when all header are replace with `ResponsiveContainerHeader`
let filterCols = $columns
.map((col) => (col.filter !== false ? buildFilterCol(col) : null))
.filter((f) => f?.options);
afterNavigate((p) => {
const paramQueries = p.to.url.searchParams.get('query');
const localQueries = queryParamToMap(paramQueries || '[]');
const localTags = Array.from(localQueries.keys());
// console.log(paramQueries, localQueries, localTags);
setFilters(localTags, filterCols, $columns);
filterCols = filterCols;
});
$inspect(filterCols);
</script>
{#if $isSmallViewport}
{#if $parsedTags?.length}
<Button
secondary
badge={`${$parsedTags?.length}`}
on:click={() => (openBottomSheet = true)}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
</Button>
{:else}
<Button secondary on:click={() => (openBottomSheet = true)}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
</Button>
{/if}
{:else}
<Menu>
{#if $parsedTags?.length}
<Button secondary badge={`${$parsedTags?.length}`}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
</Button>
{:else}
<Button secondary>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
</Button>
{/if}
<svelte:fragment slot="menu">
{#each filterCols as filter (filter.title + filter.id)}
{#if filter.options}
<QuickfiltersSubMenu
{filter}
variant={filter?.array ? 'checkbox' : 'radio'}
on:add={(e) => {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
e.detail.value,
filter?.array
? (filter.options
.filter((opt) => opt.checked)
.map((opt) => opt.value) ?? [])
: [],
$columns,
analyticsSource
);
}}
on:clear={() => {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
[],
$columns,
analyticsSource
);
}} />
{/if}
{/each}
</svelte:fragment>
<Menu>
<Button secondary badge={$parsedTags?.length ? `${$parsedTags.length}` : undefined}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
</Button>
<svelte:fragment slot="menu">
{#each filterCols.filter((f) => f?.options) as filter (filter.title + filter.id)}
{#if filter.options}
<QuickfiltersSubMenu
{filter}
variant={filter?.array ? 'checkbox' : 'radio'}
on:add={(e) => {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
e.detail.value,
filter?.array
? (filter.options
.filter((opt) => opt.checked)
.map((opt) => opt.value) ?? [])
: [],
$columns,
analyticsSource
);
}}
on:clear={() => {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
[],
$columns,
analyticsSource
);
}} />
{/if}
{/each}
</svelte:fragment>
<svelte:fragment slot="end">
<CustomFilters {columns} />
</svelte:fragment>
</Menu>
{/if}
{#if $isSmallViewport && openBottomSheet}
<FiltersBottomSheet bind:openBottomSheet {columns} {analyticsSource} bind:filterCols />
{/if}
<svelte:fragment slot="end">
<CustomFilters {columns} />
</svelte:fragment>
</Menu>
@@ -34,9 +34,10 @@
</script>
<div use:melt={$subTrigger}>
<ActionMenu.Root noPadding>
<ActionMenu.Item.Button trailingIcon={IconChevronRight}
>{filter.title}</ActionMenu.Item.Button>
<ActionMenu.Root>
<ActionMenu.Item.Button trailingIcon={IconChevronRight}>
{filter.title}
</ActionMenu.Item.Button>
</ActionMenu.Root>
</div>
+18 -15
View File
@@ -9,17 +9,18 @@ export function setFilters(localTags: TagValue[], filterCols: FilterData[], $col
if (!localTags?.length) {
filterCols.forEach((filter) => {
resetOptions(filter);
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
});
} else {
filterCols.forEach((filter) => {
if (filter.id.toLowerCase().includes('duration')) {
const id = filter?.id?.toLowerCase();
if (id?.includes('duration')) {
setTimeFilter(filter, $columns);
} else if (filter.id.toLocaleLowerCase().includes('size')) {
} else if (id?.includes('size')) {
setSizeFilter(filter, $columns);
} else if (filter.id.toLocaleLowerCase().includes('statuscode')) {
} else if (id?.includes('statuscode')) {
setStatusCodeFilter(filter, $columns);
} else if (filter.id === '$createdAt' || filter.id === '$updatedAt') {
} else if (id === '$createdat' || id === '$updatedat') {
setDateFilter(filter, $columns);
} else {
setFilterData(filter);
@@ -40,7 +41,7 @@ export function setFilterData(filter: FilterData) {
option.checked = values.includes(option.value);
});
}
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
const newTag = {
tag: tagData.tag.replace(',', ' or '),
value: tagData.value
@@ -52,7 +53,7 @@ export function setFilterData(filter: FilterData) {
});
} else {
resetOptions(filter);
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
}
}
@@ -76,7 +77,7 @@ export function setTimeFilter(filter: FilterData, columns: Column[]) {
value: timeTag.value
};
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
parsedTags.update((tags) => {
tags.push(newTag);
@@ -84,7 +85,7 @@ export function setTimeFilter(filter: FilterData, columns: Column[]) {
});
}
} else {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
}
}
@@ -102,7 +103,7 @@ export function setSizeFilter(filter: FilterData, columns: Column[]) {
return prev;
});
if (sizeRange) {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
const newTag = {
tag: `**${filter.title}** is **${sizeRange.label}**`,
@@ -114,7 +115,7 @@ export function setSizeFilter(filter: FilterData, columns: Column[]) {
});
}
} else {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
}
}
@@ -127,7 +128,7 @@ export function setStatusCodeFilter(filter: FilterData, columns: Column[]) {
const codeRange = ranges.find((c) => c?.value && c.value === statusCodeTag.value);
if (codeRange) {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
const newTag = {
tag: `**${filter.title}** is **${codeRange.label}**`,
value: statusCodeTag.value
@@ -139,7 +140,7 @@ export function setStatusCodeFilter(filter: FilterData, columns: Column[]) {
});
}
} else {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
}
}
@@ -158,7 +159,7 @@ export function setDateFilter(filter: FilterData, columns: Column[]) {
return prev;
});
if (dateRange) {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
const newTag = {
tag: `**${filter.title}** is **${dateRange.label}**`,
value: dateTag.value
@@ -169,11 +170,12 @@ export function setDateFilter(filter: FilterData, columns: Column[]) {
});
}
} else {
cleanOldTags(filter.title);
cleanOldTags(filter?.title);
}
}
function cleanOldTags(title: string) {
if (!title) return;
parsedTags.update((tags) => {
tags = tags.filter((tag) => !tag.tag.includes(`**${title}**`));
return tags;
@@ -181,6 +183,7 @@ function cleanOldTags(title: string) {
}
export function resetOptions(filter: FilterData) {
if (!filter?.options) return;
filter.options.forEach((option) => {
option.checked = false;
});
@@ -42,12 +42,12 @@
</Link>
{#if sortedDomains.length > 1}
<Popover padding="none" let:toggle>
<Popover padding="none" let:toggle placement="bottom-end">
<Tag size="xs" on:click={toggle}>
+{sortedDomains.length - 1}
</Tag>
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
<ActionMenu.Root width="20px">
{#each sortedDomains as rule, i}
{#if i !== 0}
<ActionMenu.Item.Anchor
+19 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { EmptySearch, Paginator } from '$lib/components';
import { Empty, EmptySearch, Paginator } from '$lib/components';
import { Button, InputSearch, InputSelect } from '$lib/elements/forms';
import { timeFromNow } from '$lib/helpers/date';
import { sdk } from '$lib/stores/sdk';
@@ -20,6 +20,7 @@
import { VCSDetectionType, type Models } from '@appwrite.io/console';
import { getFrameworkIcon } from '$lib/stores/sites';
import { connectGitHub } from '$lib/stores/git';
import Card from '../card.svelte';
let {
action = $bindable('select'),
@@ -79,7 +80,6 @@
selectedRepository = $repositories.repositories[0].id;
$repository = $repositories.repositories[0];
}
return $repositories.repositories;
}
@@ -148,7 +148,10 @@
);
}}
bind:value={selectedInstallation} />
<InputSearch placeholder="Search repositories" bind:value={search} />
<InputSearch
placeholder="Search repositories"
bind:value={search}
disabled={!search && !$repositories?.repositories?.length} />
</Layout.Stack>
{/await}
</Layout.Stack>
@@ -173,7 +176,10 @@
{:then response}
{#if response?.length}
<Paginator items={response} hideFooter={response?.length <= 6} limit={6}>
{#snippet children(paginatedItems)}
{#snippet children(
paginatedItems: Models.ProviderRepositoryRuntime[] &
Models.ProviderRepositoryFramework[]
)}
<Table.Root columns={1} let:root>
{#each paginatedItems as repo}
<Table.Row.Base {root}>
@@ -257,7 +263,7 @@
</Table.Root>
{/snippet}
</Paginator>
{:else}
{:else if search}
<EmptySearch hidePages hidePagination bind:search target="repositories">
<svelte:fragment slot="actions">
{#if search}
@@ -267,6 +273,14 @@
{/if}
</svelte:fragment>
</EmptySearch>
{:else}
<Card>
<Layout.Stack alignItems="center" justifyContent="center">
<Typography.Text variation="m-500" color="--fgcolor-neutral-tertiary">
No repositories available
</Typography.Text>
</Layout.Stack>
</Card>
{/if}
{/await}
{/if}
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Card, Tooltip } from '@appwrite.io/pink-svelte';
import { Card, Tooltip, Icon } from '@appwrite.io/pink-svelte';
import { type ComponentProps } from 'svelte';
import type Selector from '@appwrite.io/pink-svelte/dist/card/Selector.svelte';
+9 -8
View File
@@ -32,6 +32,12 @@
let responseTab: 'logs' | 'errors' | 'headers' | 'body' = $state('logs');
let href = $derived(
product === 'site'
? 'https://appwrite.io/docs/products/sites/logs#log-details'
: 'https://appwrite.io/docs/products/functions/develop#logging'
);
onMount(() => {
if (selectedLog?.errors) {
responseTab = 'errors';
@@ -128,8 +134,8 @@
<Input.Helper state="default">
<span>
Missing headers? Check the <Link variant="muted" href="#" external>docs</Link> to
see the supported data and how to log it.
Missing headers? Check the <Link variant="muted" {href} external>docs</Link> to see
the supported data and how to log it.
</span>
</Input.Helper>
{:else}
@@ -146,12 +152,7 @@
Body data is not captured by Appwrite for your user's security and privacy. To
display body data in the Logs tab, use <InlineCode
code="context.log()"
size="s" />. <Link
external
href="https://appwrite.io/docs/products/functions/develop#logging"
variant="muted">
Learn more</Link
>.
size="s" />. <Link external {href} variant="muted">Learn more</Link>.
</Typography.Text>
</Card>
{/if}
+3 -1
View File
@@ -79,7 +79,9 @@
on:cancel|preventDefault
{style}>
{#if show}
<slot close={closeModal} />
<div class="content">
<slot close={closeModal} />
</div>
{/if}
</dialog>
+1 -1
View File
@@ -20,4 +20,4 @@
}
</script>
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} />
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} type="link" />
+15 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
<script lang="ts" generics="T">
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import PaginationInline from './paginationInline.svelte';
import Limit from './limit.svelte';
import type { Snippet } from 'svelte';
let {
items = [],
@@ -10,7 +11,19 @@
hidePages = true,
hasLimit = false,
name = 'items',
gap = 's',
children
}: {
items: T[];
limit?: number;
hideFooter?: boolean;
hidePages?: boolean;
hasLimit?: boolean;
name?: string;
gap?:
| ('none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl')
| undefined;
children: Snippet<[T[], number]>;
} = $props();
let total = $derived(items.length);
@@ -20,7 +33,7 @@
let paginatedItems = $derived(items.slice(offset, offset + limit));
</script>
<Layout.Stack gap="s">
<Layout.Stack {gap}>
{@render children(paginatedItems, limit)}
{#if !hideFooter}
@@ -229,3 +229,14 @@
</div>
</article>
{/if}
<style lang="scss">
.table-wrapper {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
</style>
+13 -9
View File
@@ -7,7 +7,7 @@
export let maxValue: string | undefined = undefined;
export let maxUnit: string | undefined = undefined;
export let progressValue: number;
export let progressMax: number;
export let progressMax: number | undefined = undefined;
export let showBar = true;
export let progressBarData: Array<ProgressbarData> = [];
@@ -15,7 +15,7 @@
</script>
<section class="progress-bar">
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined && maxValue !== undefined}
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined}
<div class="u-flex u-flex-vertical">
<Layout.Stack direction="row" alignItems="center">
<Layout.Stack gap="s" direction="row" alignItems="baseline">
@@ -23,14 +23,18 @@
{currentValue}
</Typography.Title>
<Typography.Text>{currentUnit}</Typography.Text>
<Typography.Text color="--fgcolor-neutral-tertiary">
{maxValue}
{maxUnit ? maxUnit : ''}
</Typography.Text>
{#if maxValue !== undefined}
<Typography.Text color="--fgcolor-neutral-tertiary">
{maxValue}
{maxUnit ? maxUnit : ''}
</Typography.Text>
{/if}
</Layout.Stack>
<div>
<Badge variant="secondary" size="xs" content={`${progress}%`} />
</div>
{#if progressMax !== undefined}
<div>
<Badge variant="secondary" size="xs" content={`${progress}%`} />
</div>
{/if}
</Layout.Stack>
</div>
{/if}
+12
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
export let name: string;
export let group: string;
export let value: string | number | boolean;
export let disabled = false;
export let padding = 1;
export let autofocus = false;
export let fullHeight = true;
export let borderRadius: 'xsmall' | 'small' | 'medium' | 'large' = 'small';
@@ -13,9 +16,18 @@
medium = '--border-radius-medium',
large = '--border-radius-large'
}
let labelReference: HTMLLabelElement | null = null;
onMount(() => {
if (autofocus) {
labelReference?.focus();
}
});
</script>
<label
bind:this={labelReference}
class="box u-cursor-pointer u-flex u-flex-vertical u-gap-16"
class:is-allow-focus={!disabled}
class:is-disabled={disabled}
+20 -10
View File
@@ -12,7 +12,8 @@
Button,
Layout,
Avatar,
Typography
Typography,
Badge
} from '@appwrite.io/pink-svelte';
import {
@@ -70,7 +71,6 @@
}
$: state = $isTabletViewport ? 'closed' : getSidebarState();
$: isOnProjectSettings = /^\/console\/project-[a-zA-Z0-9-]+\/settings$/.test(page.url.pathname);
const projectOptions = [
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
@@ -78,7 +78,7 @@
{ name: 'Functions', icon: IconLightningBolt, slug: 'functions', category: 'build' },
{ name: 'Messaging', icon: IconChatBubble, slug: 'messaging', category: 'build' },
{ name: 'Storage', icon: IconFolder, slug: 'storage', category: 'build' },
{ name: 'Sites', icon: IconGlobeAlt, slug: 'sites', category: 'deploy' }
{ name: 'Sites', icon: IconGlobeAlt, slug: 'sites', category: 'deploy', badge: 'New' }
];
</script>
@@ -209,14 +209,23 @@
on:click={() => {
trackEvent(`click_menu_${projectOption.slug}`);
sideBarIsOpen = false;
}}
><span class="link-icon"
><Icon icon={projectOption.icon} size="s" />
</span><span
}}>
<span class="link-icon">
<Icon icon={projectOption.icon} size="s" />
</span>
<span
class:no-text={state === 'icons'}
class:has-text={state === 'open'}
class="link-text">{projectOption.name}</span
></a>
class="link-text">
{projectOption.name}
{#if projectOption?.badge}
<Badge
variant="secondary"
content={projectOption.badge}
size="s" />
{/if}
</span>
</a>
<span slot="tooltip">{projectOption.name}</span>
</Tooltip>
{/each}
@@ -289,7 +298,8 @@
on:click={() => {
trackEvent('click_menu_settings');
}}
class:active={isOnProjectSettings}
class:active={page.url.pathname.includes('/settings') &&
!page.url.pathname.includes('sites')}
><span class="link-icon"><Icon icon={IconCog} size="s" /></span><span
class:no-text={state === 'icons'}
class:has-text={state === 'open'}
+3 -1
View File
@@ -1,6 +1,7 @@
export const PAGE_LIMIT = 12; // default page limit
export const CARD_LIMIT = 6; // default card limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
export enum Dependencies {
FACTORS = 'dependency:factors',
@@ -502,7 +503,8 @@ export enum BillingPlan {
PRO = 'tier-1',
SCALE = 'tier-2',
GITHUB_EDUCATION = 'auto-1',
CUSTOM = 'cont-1'
CUSTOM = 'cont-1',
ENTERPRISE = 'ent-1'
}
export const feedbackDowngradeOptions = [
+3 -6
View File
@@ -12,13 +12,10 @@
export function getFlag(country: string, width: number, height: number, quality: number) {
if (!isValueOfStringEnum(Flag, country)) return '';
let flag = sdk.forConsole.avatars
return sdk.forConsole.avatars
.getFlag(country, width * 2, height * 2, quality)
?.toString();
flag?.includes('&project=')
? (flag = flag.replace('&project=', '&mode=admin'))
: flag + '&mode=admin';
return flag;
?.toString()
?.replace('&project=console', '&mode=admin');
}
</script>
+8
View File
@@ -6,6 +6,8 @@
export let name: string = id;
export let helper: string = undefined;
export let value = '';
export let pattern: string = null; //TODO: implement pattern check
export let patternError: string = '';
export let placeholder = '';
export let required = false;
export let nullable = false;
@@ -27,6 +29,12 @@
return;
}
error = inputNode.validationMessage;
if (patternError && inputNode.validity.patternMismatch) {
error = patternError;
return;
}
error = inputNode.validationMessage;
};
+1 -1
View File
@@ -6,7 +6,7 @@
export let selected: string[] = [];
export let pageItemsIds: string[] = [];
function handleClick(e: MouseEvent) {
function handleClick(e: CustomEvent) {
if (!isHTMLInputElement(e.target)) return;
if (e.target.checked) {
const set = new Set(selected);
+5 -2
View File
@@ -1,7 +1,10 @@
export function abbreviateNumber(num: number, decimals: number = 1): string {
if (isNaN(num)) return String(num);
if (num >= 1000000) {
const result = num / 1000000;
if (num >= 1_000_000_000) {
const result = num / 1_000_000_000;
return result.toFixed(result % 1 !== 0 ? decimals : 0) + 'B';
} else if (num >= 1_000_000) {
const result = num / 1_000_000;
return result.toFixed(result % 1 !== 0 ? decimals : 0) + 'M';
} else if (num >= 1000) {
const result = num / 1000;
+6
View File
@@ -101,6 +101,12 @@ export function secsToUnit(time: number, unit: Unit) {
* @returns Formatted string like "3d 4h 30m 23s"
*/
export function formatTimeDetailed(time: number): string {
if (typeof time !== 'number' || time === undefined || time === null) {
return '-';
}
if (time <= 0) {
return '0s';
}
const seconds = Math.floor(time % 60);
const minutes = Math.floor((time / 60) % 60);
const hours = Math.floor((time / (60 * 60)) % 24);
+17
View File
@@ -0,0 +1,17 @@
export function getReferrerAndUtmSource() {
if (sessionStorage) {
let values = {};
if (sessionStorage.getItem('utmReferral')) {
values = { ...values, utmReferral: sessionStorage.getItem('utmReferral') };
}
if (sessionStorage.getItem('utmSource')) {
values = { ...values, utmSource: sessionStorage.getItem('utmSource') };
}
if (sessionStorage.getItem('utmMedium')) {
values = { ...values, utmMedium: sessionStorage.getItem('utmMedium') };
}
return values;
}
return {};
}
+12 -7
View File
@@ -60,13 +60,15 @@
const dispatch = createEventDispatcher();
$: tier = tierToPlan($organization?.billingPlan)?.name;
// these can be organization level limitations as well.
// we need to migrate this sometime later, but soon!
$: hasProjectLimitation =
checkForProjectLimitation(serviceId) && $organization?.billingPlan === BillingPlan.FREE;
$: hasUsageFees = hasProjectLimitation
? checkForUsageFees($organization?.billingPlan, serviceId)
: false;
$: isLimited = limit !== 0 && limit < Infinity;
$: overflowingServices = limitedServices.filter((service) => service.value >= 0);
$: overflowingServices = limitedServices.filter((service) => service.value > 0);
$: isButtonDisabled =
buttonDisabled ||
($readOnly && !GRACE_PERIOD_OVERRIDE) ||
@@ -83,12 +85,14 @@
<!-- Show only if on Cloud, alerts are enabled, and it isn't a project limited service -->
{#if isCloud && showAlert}
{#if $readOnly}
{@const services = overflowingServices
.map((s) => {
return s.name.toLocaleLowerCase();
})
.join(', ')}
<!-- some services are above limit -->
{@const services = overflowingServices
.map((s) => {
return s.name.toLocaleLowerCase();
})
.join(', ')}
{#if services.length}
<slot name="alert" {limit} {tier} {title} {upgradeMethod} {hasUsageFees} {services}>
{#if $organization?.billingPlan !== BillingPlan.FREE && hasUsageFees}
<Alert type="info" isStandalone>
@@ -131,6 +135,7 @@
<svelte:fragment slot="list">
<slot name="tooltip" {limit} {tier} {title} {upgradeMethod} {hasUsageFees}>
{#if hasProjectLimitation}
{@const count = limit > 1 ? serviceId : serviceId.replace('s', '')}
<p class="text">
You are limited to {limit}
{title.toLocaleLowerCase()} per project on the {tier} plan.
+8 -1
View File
@@ -156,11 +156,18 @@
}
footer {
margin-block-start: auto;
margin-inline: 2rem;
padding-block: 1rem;
display: flex;
flex-direction: column;
gap: var(--gap-l);
margin-inline: 2rem;
}
:global(main:has(.sub-navigation)) footer {
@media (min-width: 1024px) {
margin-inline-start: -1.5rem;
margin-inline-end: 2rem;
}
}
.extra-margin {
margin-block-start: var(--space-2, 4px);
@@ -46,9 +46,7 @@
let showFilters = $state(false);
let filterCols = $derived(
$columns
.map((col) => (col.filter !== false ? buildFilterCol(col) : null))
.filter((f) => f?.options)
$columns.map((col) => (col.filter !== false ? buildFilterCol(col) : null)).filter(Boolean)
);
afterNavigate((p) => {
@@ -109,14 +107,16 @@
<SearchQuery placeholder={searchPlaceholder} />
{/if}
{#if hasFilters && $columns?.length}
<QuickFilters {columns} {analyticsSource} />
<QuickFilters {columns} {analyticsSource} {filterCols} />
{/if}
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if hasDisplaySettings}
<ViewSelector {view} {columns} {hideView} {hideColumns} />
{/if}
{@render children()}
{#if children}
{@render children()}
{/if}
</Layout.Stack>
</Layout.Stack>
{/if}
@@ -138,7 +138,7 @@
bind:openBottomSheet={showFilters}
{columns}
{analyticsSource}
bind:filterCols />
filterCols={filterCols.filter((f) => f?.options)} />
{/if}
<!-- SNIPPETS -->
+4 -9
View File
@@ -10,6 +10,7 @@
import type { Campaign } from '$lib/stores/campaigns';
import { getApiEndpoint } from '$lib/stores/sdk';
import { Typography, Layout, Avatar } from '@appwrite.io/pink-svelte';
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
export const imgLight = LoginLight;
export const imgDark = LoginDark;
@@ -40,12 +41,6 @@
return campaign.description;
}
}
function getImage(image: string) {
const endpoint = getApiEndpoint();
const url = new URL(image, endpoint);
return url.toString();
}
</script>
<main class="grid-1-1 is-full-page" id="main">
@@ -88,7 +83,7 @@
<div style:max-inline-size="30rem" style:height="100%">
<Layout.Stack justifyContent="center" alignItems="center" height="100%" gap="xxxl">
<img
src={getImage(campaign?.image[$app.themeInUse])}
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
class="u-block u-image-object-fit-cover side-bg-img"
alt="promo" />
@@ -114,7 +109,7 @@
<Layout.Stack gap="s" direction="row">
{#if currentReview?.image}
<Avatar
src={getImage(currentReview?.image)}
src={getCampaignImageUrl(currentReview?.image)}
alt={currentReview.name}
size="m" />
{:else}
@@ -147,7 +142,7 @@
</p>
<img
style:max-block-size="2.5rem"
src={getImage(campaign?.image[$app.themeInUse])}
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
alt={coupon?.campaign ?? campaign.$id} />
</div>
{/if}
+15 -2
View File
@@ -41,10 +41,20 @@
export function accumulateFromEndingTotal(
metrics: Models.Metric[],
endingTotal: number
endingTotal: number,
startingDayToFillZero: Date = null
): Array<[string, number]> {
return (metrics ?? []).reduceRight(
(acc, curr) => {
if (startingDayToFillZero !== null && startingDayToFillZero instanceof Date) {
const date = new Date(curr.date);
if (date > startingDayToFillZero) {
acc.data.unshift([date.toISOString(), 0]);
acc.total -= 0;
return acc;
}
}
acc.data.unshift([curr.date, acc.total]);
acc.total -= curr.value;
return acc;
@@ -77,6 +87,7 @@
export let countMetadata: MetricMetadata;
export let path: string = null;
export let hidePeriodSelect = false;
export let isCumulative: boolean = false;
</script>
<Layout.Stack gap="s">
@@ -116,7 +127,9 @@
series={[
{
name: countMetadata.legend,
data: accumulateFromEndingTotal(count, total)
data: isCumulative
? count.map((m) => [m.date, m.value])
: accumulateFromEndingTotal(count, total)
}
]} />
</div>
+5 -1
View File
@@ -13,6 +13,7 @@
export let count: Models.Metric[][];
export let legendData: LegendData[];
export let showHeader: boolean = true;
export let legendNumberFormat: 'comma' | 'abbreviate' = 'comma';
</script>
<div>
@@ -55,7 +56,10 @@
}))} />
{#if legendData}
<Legend {legendData} />
<Legend
{legendData}
decimalsForAbbreviate={2}
numberFormat={legendNumberFormat} />
{/if}
</div>
{/if}
+5 -1
View File
@@ -1,8 +1,12 @@
<script>
export let hideSidebar = false;
</script>
<div class="wizard-secondary-content">
<div class="wizard-secondary-content-1">
<slot />
</div>
{#if $$slots.aside}
{#if !hideSidebar}
<div class="wizard-secondary-content-sep"></div>
<div class="wizard-secondary-content-2">
<slot name="aside" />
+198 -27
View File
@@ -1,8 +1,8 @@
import type { Client, Models } from '@appwrite.io/console';
import type { Organization, OrganizationList } from '../stores/organization';
import type { PaymentMethod } from '@stripe/stripe-js';
import type { Tier } from '$lib/stores/billing';
import type { Campaign } from '$lib/stores/campaigns';
import type { Client, Models } from '@appwrite.io/console';
import type { PaymentMethod } from '@stripe/stripe-js';
import type { Organization, OrganizationError, OrganizationList } from '../stores/organization';
export type PaymentMethodData = {
$id: string;
@@ -65,6 +65,32 @@ export type InvoiceList = {
total: number;
};
export type Estimation = {
amount: number;
grossAmount: number;
credits: number;
discount: number;
items: EstimationItem[];
discounts: EstimationItem[];
trialDays: number;
trialEndDate: string | undefined;
error: string | undefined;
};
export type EstimationItem = {
label: string;
value: number;
};
export type EstimationDeleteOrganization = {
amount: number;
grossAmount: number;
credits: number;
discount: number;
items: EstimationItem[];
unpaidInvoices: Invoice[];
};
export type Coupon = {
$id: string;
code: string;
@@ -147,10 +173,12 @@ export type Aggregation = {
* Total amount of the invoice.
*/
amount: number;
additionalMembers: number;
/**
* Price for additional members
*/
additionalMembers: number;
additionalMemberAmount: number;
/**
* Total storage usage.
*/
@@ -175,6 +203,10 @@ export type Aggregation = {
* Usage logs for the billing period.
*/
resources: OrganizationUsage;
/**
* Aggregation billing plan
*/
plan: string;
};
export type OrganizationUsage = {
@@ -182,11 +214,13 @@ export type OrganizationUsage = {
executions: Array<Models.Metric>;
databasesReads: Array<Models.Metric>;
databasesWrites: Array<Models.Metric>;
imageTransformations: Array<Models.Metric>;
executionsTotal: number;
filesStorageTotal: number;
buildsStorageTotal: number;
databasesReadsTotal: number;
databasesWritesTotal: number;
imageTransformationsTotal: number;
deploymentsStorageTotal: number;
executionsMBSecondsTotal: number;
buildsMBSecondsTotal: number;
@@ -204,6 +238,7 @@ export type OrganizationUsage = {
users: number;
authPhoneTotal: number;
authPhoneEstimate: number;
imageTransformations: number;
}>;
authPhoneTotal: number;
authPhoneEstimate: number;
@@ -264,14 +299,25 @@ export type AdditionalResource = {
multiplier?: number;
};
export type PlanAddon = {
supported: boolean;
currency: string;
invoiceDesc: string;
price: number;
limit: number;
value: number;
type: string;
};
export type Plan = {
$id: string;
name: string;
desc: string;
price: number;
order: number;
bandwidth: number;
storage: number;
members: number;
imageTransformations: number;
webhooks: number;
users: number;
teams: number;
@@ -283,7 +329,7 @@ export type Plan = {
realtime: number;
logs: number;
authPhone: number;
addons: {
usage: {
bandwidth: AdditionalResource;
executions: AdditionalResource;
member: AdditionalResource;
@@ -291,7 +337,11 @@ export type Plan = {
storage: AdditionalResource;
users: AdditionalResource;
};
addons: {
seats: PlanAddon;
};
trialDays: number;
budgetCapEnabled: boolean;
isAvailable: boolean;
selfService: boolean;
premiumSupport: boolean;
@@ -300,9 +350,10 @@ export type Plan = {
backupsEnabled: boolean;
backupPolicies: number;
emailBranding: boolean;
supportsCredits: boolean;
};
export type PlansInfo = {
export type PlanList = {
plans: Plan[];
total: number;
};
@@ -337,20 +388,45 @@ export class Billing {
);
}
async validateOrganization(organizationId: string, invites: string[]): Promise<Organization> {
const path = `/organizations/${organizationId}/validate`;
const params = {
organizationId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'PATCH',
uri,
{
'content-type': 'application/json'
},
params
);
}
async createOrganization(
organizationId: string,
name: string,
billingPlan: string,
paymentMethodId: string,
billingAddressId: string = undefined
): Promise<Organization> {
billingAddressId: string = null,
couponId: string = null,
invites: Array<string> = [],
budget: number = undefined,
taxId: string = null
): Promise<Organization | OrganizationError> {
const path = `/organizations`;
const params = {
organizationId,
name,
billingPlan,
paymentMethodId,
billingAddressId
billingAddressId,
couponId,
invites,
budget,
taxId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
@@ -363,6 +439,28 @@ export class Billing {
);
}
async estimationCreateOrganization(
billingPlan: string,
couponId: string = null,
invites: Array<string> = []
): Promise<Estimation> {
const path = `/organizations/estimations/create-organization`;
const params = {
billingPlan,
couponId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
uri,
{
'content-type': 'application/json'
},
params
);
}
async deleteOrganization(organizationId: string): Promise<Organization> {
const path = `/organizations/${organizationId}`;
const params = {
@@ -379,20 +477,30 @@ export class Billing {
);
}
async getPlan(organizationId: string): Promise<Plan> {
const path = `/organizations/${organizationId}/plan`;
const params = {
organizationId
};
async estimationDeleteOrganization(
organizationId: string
): Promise<EstimationDeleteOrganization> {
const path = `/organizations/${organizationId}/estimations/delete-organization`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'get',
uri,
{
'content-type': 'application/json'
},
params
);
return await this.client.call('patch', uri, {
'content-type': 'application/json'
});
}
async getOrganizationPlan(organizationId: string): Promise<Plan> {
const path = `/organizations/${organizationId}/plan`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('get', uri, {
'content-type': 'application/json'
});
}
async getPlan(planId: string): Promise<Plan> {
const path = `/console/plans/${planId}`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('get', uri, {
'content-type': 'application/json'
});
}
async getRoles(organizationId: string): Promise<Roles> {
@@ -407,14 +515,22 @@ export class Billing {
organizationId: string,
billingPlan: string,
paymentMethodId: string,
billingAddressId: string = undefined
): Promise<Organization> {
billingAddressId: string = undefined,
couponId: string = null,
invites: Array<string> = [],
budget: number = undefined,
taxId: string = null
): Promise<Organization | OrganizationError> {
const path = `/organizations/${organizationId}/plan`;
const params = {
organizationId,
billingPlan,
paymentMethodId,
billingAddressId
billingAddressId,
couponId,
invites,
budget,
taxId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
@@ -427,6 +543,37 @@ export class Billing {
);
}
async estimationUpdatePlan(
organizationId: string,
billingPlan: string,
couponId: string = null,
invites: Array<string> = []
): Promise<Estimation> {
const path = `/organizations/${organizationId}/estimations/update-plan`;
const params = {
billingPlan,
couponId,
invites
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
uri,
{
'content-type': 'application/json'
},
params
);
}
async cancelDowngrade(organizationId: string): Promise<Organization | OrganizationError> {
const path = `/organizations/${organizationId}/plan/cancel`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('patch', uri, {
'content-type': 'application/json'
});
}
async updateBudget(
organizationId: string,
budget: number,
@@ -594,6 +741,14 @@ export class Billing {
);
}
async updateInvoiceStatus(organizationId: string, invoiceId: string): Promise<Invoice> {
const path = `/organizations/${organizationId}/invoices/${invoiceId}/status`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('PATCH', uri, {
'content-type': 'application/json'
});
}
async retryPayment(
organizationId: string,
invoiceId: string,
@@ -750,6 +905,22 @@ export class Billing {
}
}
async getCouponAccount(couponId: string): Promise<Coupon> {
const path = `/account/coupons/${couponId}`;
const params = {
couponId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'GET',
uri,
{
'content-type': 'application/json'
},
params
);
}
async getCoupon(couponId: string): Promise<Coupon> {
const path = `/console/coupons/${couponId}`;
const params = {
@@ -1120,7 +1291,7 @@ export class Billing {
);
}
async getPlansInfo(): Promise<PlansInfo> {
async getPlansInfo(): Promise<PlanList> {
const path = `/console/plans`;
const params = {};
const uri = new URL(this.client.config.endpoint + path);
+19 -1
View File
@@ -185,6 +185,14 @@ export type UsageBuckets = {
* Aggregated statistics of bucket storage files per period.
*/
storage: Metric[];
/**
* Aggregated statistics of bucket image transformations per period.
*/
imageTransformations: Metric[];
/**
* Total aggregated number of bucket image transformations.
*/
imageTransformationsTotal: number;
};
/**
* UsageFunctions
@@ -311,7 +319,17 @@ export type UsageProject = {
authPhoneEstimate: number;
/**
* Aggregated statistics of total number SMS by country
* Aggregated statistics of total number SMS by country.
*/
authPhoneCountriesBreakdown: Models.MetricBreakdown[];
/**
* Array of image transformations per period.
*/
imageTransformations: Metric[];
/**
* Aggregated statistics of total number of image transformations.
*/
imageTransformationsTotal: number;
};
+113 -54
View File
@@ -1,40 +1,41 @@
import { page } from '$app/stores';
import { derived, get, writable } from 'svelte/store';
import { sdk } from './sdk';
import { organization, type Organization } from './organization';
import type {
InvoiceList,
AddressesList,
Invoice,
PaymentList,
PlansMap,
PaymentMethodData,
OrganizationUsage,
Plan
} from '$lib/sdk/billing';
import { isCloud } from '$lib/system';
import { cachedStore } from '$lib/helpers/cache';
import { Query } from '@appwrite.io/console';
import { headerAlert } from './headerAlert';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import { addNotification, notifications } from './notifications';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
import { BillingPlan } from '$lib/constants';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { page } from '$app/stores';
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
import { last } from '$lib/helpers/array';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
import { sizeToBytes, type Size } from '$lib/helpers/sizeConvertion';
import { user } from './user';
import { browser } from '$app/environment';
import type {
AddressesList,
Aggregation,
Invoice,
InvoiceList,
PaymentList,
PaymentMethodData,
Plan,
PlansMap
} from '$lib/sdk/billing';
import { isCloud } from '$lib/system';
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
import { Query } from '@appwrite.io/console';
import { derived, get, writable } from 'svelte/store';
import { headerAlert } from './headerAlert';
import { addNotification, notifications } from './notifications';
import { organization, type Organization, type OrganizationError } from './organization';
import { canSeeBilling } from './roles';
import { sdk } from './sdk';
import { user } from './user';
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
export const roles = [
{
@@ -59,12 +60,20 @@ export const roles = [
}
];
export const teamStatusReadonly = 'readonly';
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
export const daysLeftInTrial = writable<number>(0);
export const readOnly = writable<boolean>(false);
export const showBudgetAlert = derived(
page,
($page) => ($page.data.organization?.billingLimits.budgetLimit ?? 0) >= 100
);
export function getRoleLabel(role: string) {
return roles.find((r) => r.value === role)?.label ?? role;
}
@@ -81,6 +90,8 @@ export function tierToPlan(tier: Tier) {
return tierGitHubEducation;
case BillingPlan.CUSTOM:
return tierCustom;
case BillingPlan.ENTERPRISE:
return tierEnterprise;
default:
return tierFree;
}
@@ -130,7 +141,8 @@ export type PlanServices =
| 'usersAddon'
| 'webhooks'
| 'sites'
| 'authPhone';
| 'authPhone'
| 'imageTransformations';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
if (!isCloud) return 0;
@@ -138,6 +150,14 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
const info = get(plansInfo);
if (!info) return 0;
plan ??= info.get(tier ?? get(organization)?.billingPlan);
// members are no longer a variable on plan itself!
// the correct info for members/seats, resides in `addons`.
// plan > addons > seats/others
if (serviceId === 'members') {
// some don't include `limit`, so we fallback!
return plan?.['addons']['seats']['limit'] ?? 1;
}
return plan?.[serviceId] ?? 0;
}
@@ -151,11 +171,12 @@ export const failedInvoice = cachedStore<
load: async (orgId) => {
if (!isCloud) set(null);
if (!get(canSeeBilling)) set(null);
const invoices = await sdk.forConsole.billing.listInvoices(orgId);
const failedInvoices = invoices.invoices.filter((i) => i.status === 'failed');
const failedInvoices = await sdk.forConsole.billing.listInvoices(orgId, [
Query.equal('status', 'failed')
]);
// const failedInvoices = invoices.invoices;
if (failedInvoices?.length > 0) {
const firstFailed = failedInvoices[0];
if (failedInvoices?.invoices?.length > 0) {
const firstFailed = failedInvoices.invoices[0];
const today = new Date();
const thirtyDaysAgo = new Date(today.setDate(today.getDate() - 30));
const failedDate = new Date(firstFailed.$createdAt);
@@ -200,6 +221,11 @@ export const tierCustom: TierData = {
description: 'Team on a custom contract'
};
export const tierEnterprise: TierData = {
name: 'Enterprise',
description: 'For enterprises that need more power and premium support.'
};
export const showUsageRatesModal = writable<boolean>(false);
export function checkForUsageFees(plan: Tier, id: PlanServices) {
@@ -257,13 +283,35 @@ export function calculateTrialDay(org: Organization) {
}
export async function checkForUsageLimit(org: Organization) {
if (org?.billingPlan !== BillingPlan.FREE) {
if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
headerAlert.add({
id: 'teamReadOnlyFailedInvoices',
component: TeamReadonlyAlert,
show: true,
importance: 11
});
readOnly.set(true);
return;
}
if (!org?.billingLimits && org?.status !== teamStatusReadonly) {
readOnly.set(false);
return;
}
if (!org?.billingLimits) {
readOnly.set(false);
return;
if (org?.billingPlan !== BillingPlan.FREE) {
const { budgetLimit } = org?.billingLimits ?? {};
if (budgetLimit && budgetLimit >= 100) {
readOnly.set(false);
headerAlert.add({
id: 'budgetLimit',
component: BudgetLimitAlert,
show: true,
importance: 10
});
readOnly.set(true);
return;
}
}
const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
const resources = [
@@ -276,7 +324,8 @@ export async function checkForUsageLimit(org: Organization) {
const members = org.total;
const plan = get(plansInfo)?.get(org.billingPlan);
const membersOverflow = members > plan.members ? members - (plan.members || members) : 0;
const membersOverflow =
members - 1 > plan.addons.seats.limit ? members - (plan.addons.seats.limit || members) : 0;
if (resources.some((r) => r.value >= 100) || membersOverflow > 0) {
readOnly.set(true);
@@ -456,30 +505,36 @@ export async function checkForNewDevUpgradePro(org: Organization) {
if (now - accountCreated < 1000 * 60 * 60 * 24 * 7) return;
const isDismissed = !!localStorage.getItem('newDevUpgradePro');
if (isDismissed) return;
if (now - accountCreated < 1000 * 60 * 60 * 24 * 37) {
headerAlert.add({
id: 'newDevUpgradePro',
component: newDevUpgradePro,
show: true,
importance: 1
});
// check if coupon already applied
try {
await sdk.forConsole.billing.getCouponAccount(NEW_DEV_PRO_UPGRADE_COUPON);
} catch (e) {
return;
}
headerAlert.add({
id: 'newDevUpgradePro',
component: newDevUpgradePro,
show: true,
importance: 1
});
}
export const upgradeURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/change-plan`
);
export const billingURL = derived(
page,
($page) => `${base}/organization-${$page.data?.organization?.$id}/billing`
);
export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account'];
export function calculateExcess(usage: OrganizationUsage, plan: Plan, members: number) {
const totBandwidth = usage?.bandwidth?.length > 0 ? last(usage.bandwidth).value : 0;
export function calculateExcess(addon: Aggregation, plan: Plan) {
return {
bandwidth: calculateResourceSurplus(totBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(usage?.storageTotal, plan.storage, 'GB'),
users: calculateResourceSurplus(usage?.usersTotal, plan.users),
executions: calculateResourceSurplus(usage?.executionsTotal, plan.executions, 'GB'),
members: calculateResourceSurplus(members, plan.members)
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
executions: calculateResourceSurplus(addon.usageExecutions, plan.executions, 'GB'),
members: addon.additionalMembers
};
}
@@ -488,3 +543,7 @@ export function calculateResourceSurplus(total: number, limit: number, limitUnit
const realLimit = (limitUnit ? sizeToBytes(limit, limitUnit) : limit) || Infinity;
return total > realLimit ? total - realLimit : 0;
}
export function isOrganization(org: Organization | OrganizationError): org is Organization {
return (org as Organization).$id !== undefined;
}
+14 -9
View File
@@ -1,10 +1,12 @@
import { derived, writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { page } from '$app/stores';
import { type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { headerAlert } from '$lib/stores/headerAlert';
import BackupDatabase from '$lib/components/backupDatabaseAlert.svelte';
import { shouldShowNotification } from '$lib/helpers/notifications';
import { isCloud } from '$lib/system';
import { currentPlan } from '$lib/stores/organization';
export const database = derived(page, ($page) => $page.data?.database as Models.Database);
@@ -17,16 +19,19 @@ export async function checkForDatabaseBackupPolicies(database: Models.Database)
if (!shouldShowNotification(backupsBannerId)) return;
let total = 0;
const backupsEnabled = get(currentPlan)?.backupsEnabled ?? true;
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
if (isCloud && backupsEnabled) {
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
}
}
showPolicyAlert.set(total <= 0);
+22 -7
View File
@@ -115,10 +115,26 @@ function createFeedbackStore() {
email?: string,
// eslint-disable-next-line
billingPlan?: string,
value?: number
value?: number,
orgId?: string,
projectId?: string,
userId?: string
) => {
if (!VARS.GROWTH_ENDPOINT) return;
trackEvent(Submit.FeedbackSubmit);
const customFields: Array<{ id: string; value: string | number }> = [
{ id: '47364', value: currentPage }
];
if (value) {
customFields.push({ id: '40655', value });
}
if (billingPlan) {
customFields.push({ id: '56109', value: billingPlan });
}
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/feedback`, {
method: 'POST',
headers: {
@@ -128,14 +144,13 @@ function createFeedbackStore() {
subject,
message,
email,
// billingPlan,
customFields,
firstname: name || 'Unknown',
customFields: [
{ id: '47364', value: currentPage },
...(value ? [{ id: '40655', value }] : [])
],
metaFields: {
source: get(feedback).source
source: get(feedback).source,
orgId,
projectId,
userId
}
})
});
+6
View File
@@ -101,6 +101,12 @@ export const oAuthProviders: Record<string, Provider> = {
docs: 'https://developers.facebook.com/',
component: Main
},
figma: {
name: 'Figma',
icon: 'figma',
docs: 'https://www.figma.com/developers/api#access-tokens',
component: Main
},
github: {
name: 'GitHub',
icon: 'github',
+14
View File
@@ -4,6 +4,15 @@ import type { Models } from '@appwrite.io/console';
import type { Tier } from './billing';
import type { Plan } from '$lib/sdk/billing';
export type OrganizationError = {
status: number;
message: string;
teamId: string;
invoiceId: string;
clientSecret: string;
type: string;
};
export type Organization = Models.Team<Record<string, unknown>> & {
billingBudget: number;
billingPlan: Tier;
@@ -21,6 +30,10 @@ export type Organization = Models.Team<Record<string, unknown>> & {
amount: number;
billingTaxId?: string;
billingPlanDowngrade?: Tier;
billingAggregationId: string;
billingInvoiceId: string;
status: string;
remarks: string;
};
export type OrganizationList = {
@@ -34,6 +47,7 @@ export type BillingLimits = {
executions: number;
storage: number;
users: number;
budgetLimit: number;
};
export const newOrgModal = writable<boolean>(false);
@@ -14,10 +14,10 @@
<svelte:fragment slot="aside">
<BoxAvatar>
<svelte:fragment slot="image">
<AvatarInitials size="l" name={$user.name} />
<AvatarInitials size="m" name={$user.name || $user.email} />
</svelte:fragment>
<svelte:fragment slot="title">
<span class="u-bold u-trim-1" data-private>{$user.name}</span>
<span class="u-bold u-trim-1" data-private>{$user.name || 'User'}</span>
</svelte:fragment>
</BoxAvatar>
</svelte:fragment>
@@ -22,10 +22,6 @@
onMount(async () => {
const countryList = await sdk.forProject.locale.listCountries();
const locale = await sdk.forProject.locale.get();
if (locale.countryCode) {
selectedAddress.country = locale.countryCode;
}
options = countryList.countries.map((country) => {
return {
value: country.code,
@@ -57,6 +53,21 @@
trackError(e, Submit.BillingAddressUpdate);
}
}
/**
* This component isn't a modal, so it mounts with its parent.
* On mount, `selectedAddress` can be `null`, so we set the country when the modal shows.
*
* And we only run this if the selected address has no country set,
* which really shouldn't happen, but here we are, playing it safe!
*/
$: if (show && !selectedAddress.country) {
sdk.forProject.locale.get().then((locale) => {
if (locale.countryCode) {
selectedAddress.country = locale.countryCode;
}
});
}
</script>
<Modal bind:error bind:show onSubmit={handleSubmit} size="m" title="Update billing address">
@@ -12,7 +12,7 @@
let name: string = null;
onMount(async () => {
name ??= $user.name;
name ??= $user.name ?? '';
});
async function updateName() {
+102 -53
View File
@@ -3,25 +3,27 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
CreditsApplied,
EstimatedTotalBox,
SelectPaymentMethod
} from '$lib/components/billing';
import { CreditsApplied, EstimatedTotal, SelectPaymentMethod } from '$lib/components/billing';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputText } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { Wizard } from '$lib/layout';
import { type PaymentList } from '$lib/sdk/billing';
import { type PaymentList, type Plan } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import { organizationList, type Organization } from '$lib/stores/organization';
import {
organizationList,
type Organization,
type OrganizationError
} from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe.js';
import { ID } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { plansInfo } from '$lib/stores/billing';
import { isOrganization, plansInfo, type Tier } from '$lib/stores/billing';
import { Fieldset, Icon, Layout, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
export let data;
@@ -66,13 +68,19 @@
let coupon: string;
let couponData = data?.couponData;
let campaign = data?.campaign;
let billingPlan = BillingPlan.PRO;
let billingPlan: Tier = BillingPlan.PRO;
let tempOrgId = null;
let currentPlan: Plan;
$: onlyNewOrgs = (campaign && campaign.onlyNewOrgs) || (couponData && couponData.onlyNewOrgs);
$: selectedOrgId = tempOrgId;
function isUpgrade() {
const newPlan = page.data.plansInfo.get(billingPlan);
return currentPlan && newPlan && currentPlan.order < newPlan.order;
}
onMount(async () => {
await loadPaymentMethods();
if (!$organizationList?.total || campaign?.onlyNewOrgs) {
@@ -89,6 +97,19 @@
if ($organizationList.total > 0 && tempOrgId === null) {
tempOrgId = $organizationList.teams[0].$id;
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
collaborators = page.url.searchParams.get('invites').split(',');
await sdk.forConsole.billing.validateOrganization(organizationId, collaborators);
}
}
if (!selectedOrgId && $organizationList?.total) {
selectedOrgId = $organizationList.teams[0].$id;
}
});
async function loadPaymentMethods() {
@@ -103,76 +124,82 @@
async function handleSubmit() {
if (!couponForm.checkValidity()) return;
isSubmitting.set(true);
try {
let org: Organization;
let org: Organization | OrganizationError;
// Create new org
if (selectedOrgId === newOrgId) {
org = await sdk.forConsole.billing.createOrganization(
newOrgId,
name,
billingPlan,
paymentMethodId
paymentMethodId,
null,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
taxId
);
}
// Upgrade existing org
else if (selectedOrg?.billingPlan !== billingPlan) {
else if (selectedOrg?.billingPlan !== billingPlan && isUpgrade()) {
org = await sdk.forConsole.billing.updatePlan(
selectedOrg.$id,
billingPlan,
paymentMethodId,
null
null,
couponData.code ? couponData.code : null,
collaborators
);
}
// Existing pro org
// Existing pro org, apply credits
else {
org = selectedOrg;
}
// Add coupon
if (couponData?.code) {
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
}
// Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
if (!isOrganization(org) && org.status === 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('org', org.teamId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/apply-credit?' + params.toString()
);
org = await sdk.forConsole.billing.validateOrganization(org.teamId, collaborators);
}
// Add collaborators
if (collaborators?.length) {
collaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['owner'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
if (isOrganization(org)) {
trackEvent(Submit.CreditRedeem, {
coupon: couponData.code,
campaign: couponData?.campaign
});
await invalidate(Dependencies.ORGANIZATION);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
await invalidate(Dependencies.ACCOUNT);
}
// Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
}
trackEvent(Submit.CreditRedeem, {
coupon: couponData.code,
campaign: couponData?.campaign
});
await invalidate(Dependencies.ORGANIZATION);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
await invalidate(Dependencies.ACCOUNT);
} catch (e) {
trackError(e, Submit.CreditRedeem);
addNotification({
type: 'error',
message: e.message
});
} finally {
isSubmitting.set(false);
}
}
@@ -197,10 +224,32 @@
(team) => team.$id === selectedOrgId
) as Organization;
$: billingPlan =
selectedOrg?.billingPlan === BillingPlan.SCALE
? BillingPlan.SCALE
: (campaign?.plan ?? BillingPlan.PRO);
function getBillingPlan(): Tier | undefined {
const campaignPlan =
campaign?.plan && $plansInfo.get(campaign.plan) ? $plansInfo.get(campaign.plan) : null;
const orgPlan =
selectedOrg?.billingPlan && $plansInfo.get(selectedOrg.billingPlan)
? $plansInfo.get(selectedOrg.billingPlan)
: null;
if (!campaignPlan || !orgPlan) {
return;
}
return campaignPlan.order > orgPlan.order ? campaign.plan : selectedOrg?.billingPlan;
}
$: if (selectedOrg) {
billingPlan = getBillingPlan();
}
$: {
if (selectedOrgId) {
(async () => {
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
})();
}
}
</script>
<svelte:head>
@@ -286,7 +335,7 @@
</Form>
</Layout.Stack>
<svelte:fragment slot="aside">
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE && selectedOrg?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
{#if selectedOrg?.$id && selectedOrg?.billingPlan === billingPlan}
<section
class="card"
style:--p-card-padding="1.5rem"
+2 -3
View File
@@ -3,16 +3,15 @@ import type { Coupon } from '$lib/sdk/billing.js';
import type { Campaign } from '$lib/stores/campaigns.js';
import { sdk } from '$lib/stores/sdk.js';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
export const load = async ({ url }) => {
// Has promo code
if (url.searchParams.has('code')) {
let couponData: Coupon;
let campaign: Campaign;
const code = url.searchParams.get('code');
try {
couponData = await sdk.forConsole.billing.getCoupon(code);
couponData = await sdk.forConsole.billing.getCouponAccount(code);
if (couponData.campaign) {
campaign = await sdk.forConsole.billing.getCampaign(couponData.campaign);
}
@@ -4,24 +4,28 @@
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
EstimatedTotalBox,
PlanComparisonBox,
PlanSelection,
SelectPaymentMethod
SelectPaymentMethod,
SelectPlan
} from '$lib/components/billing';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, InputTags, InputText } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import type { Coupon } from '$lib/sdk/billing';
import { tierToPlan } from '$lib/stores/billing';
import type { Coupon, PaymentList } from '$lib/sdk/billing';
import { isOrganization, tierToPlan } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import type { Organization } from '$lib/stores/organization';
import { type OrganizationError, type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { ID } from '@appwrite.io/console';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Divider, Fieldset, Icon, Layout, Link, Typography } from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
import { onMount } from 'svelte';
export let data;
@@ -29,13 +33,22 @@
let selectedCoupon: Partial<Coupon> | null = data.coupon;
let previousPage: string = base;
let showExitModal = false;
let formComponent: Form;
let isSubmitting = writable(false);
let methods: PaymentList;
let name: string;
let paymentMethodId: string =
data.paymentMethods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
let billingPlan: BillingPlan = BillingPlan.FREE;
let paymentMethodId: string;
let collaborators: string[] = [];
let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
let taxId: string;
let billingBudget: number;
let showCreditModal = false;
@@ -43,9 +56,73 @@
previousPage = from?.url?.pathname || previousPage;
});
onMount(async () => {
if (page.url.searchParams.has('coupon')) {
const coupon = page.url.searchParams.get('coupon');
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
if (page.url.searchParams.has('name')) {
name = page.url.searchParams.get('name');
}
if (page.url.searchParams.has('plan')) {
const plan = page.url.searchParams.get('plan');
if (plan && Object.values(BillingPlan).includes(plan as BillingPlan)) {
billingPlan = plan as BillingPlan;
}
}
if (
data?.hasFreeOrganizations ||
(page.url.searchParams.has('type') && page.url.searchParams.get('type') === 'createPro')
) {
billingPlan = BillingPlan.PRO;
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
const invites = page.url.searchParams.get('invites').split(',');
await validate(organizationId, invites);
}
}
});
async function loadPaymentMethods() {
methods = await sdk.forConsole.billing.listPaymentMethods();
paymentMethodId = methods.paymentMethods.find((method) => !!method?.last4)?.$id ?? null;
}
async function validate(organizationId: string, invites: string[]) {
try {
const org = await sdk.forConsole.billing.validateOrganization(organizationId, invites);
if (isOrganization(org)) {
await preloadData(`${base}/console/organization-${org.$id}`);
await goto(`${base}/console/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
}
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
trackError(e, Submit.OrganizationCreate);
}
}
async function create() {
try {
let org: Organization;
let org: Organization | OrganizationError;
if (selectedPlan === BillingPlan.FREE) {
org = await sdk.forConsole.billing.createOrganization(
@@ -61,53 +138,49 @@
name,
selectedPlan,
paymentMethodId,
null
null,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
taxId
);
//Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
}
//Add coupon
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
//Add collaborators
if (collaborators?.length) {
collaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['developer'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
});
}
// Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
if (!isOrganization(org) && org.status === 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
params.append('type', 'payment_confirmed');
params.append('id', org.teamId);
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('invites', collaborators.join(','));
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/create-organization?' + params.toString()
);
await validate(org.teamId, collaborators);
}
}
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(selectedPlan)?.name,
budget_cap_enabled: !!billingBudget,
plan: tierToPlan(billingPlan)?.name,
budget_cap_enabled: billingBudget !== null,
members_invited: collaborators?.length
});
await invalidate(Dependencies.ACCOUNT);
await preloadData(`${base}/organization-${org.$id}`);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${name ?? 'Organization'} has been created`
});
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await preloadData(`${base}/organization-${org.$id}`);
await goto(`${base}/organization-${org.$id}`);
addNotification({
type: 'success',
message: `${org.name ?? 'Organization'} has been created`
});
}
} catch (e) {
addNotification({
type: 'error',
@@ -27,7 +27,7 @@ export const load: LayoutLoad = async ({ params, depends }) => {
const res = await sdk.forConsole.billing.getRoles(params.organization);
roles = res.roles;
scopes = res.scopes;
currentPlan = await sdk.forConsole.billing.getPlan(params.organization);
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(params.organization);
if (scopes.includes('billing.read')) {
await failedInvoice.load(params.organization);
if (get(failedInvoice)) {
@@ -24,7 +24,6 @@
import { ID, Region } from '@appwrite.io/console';
import { openImportWizard } from '../project-[project]/settings/migrations/(import)';
import { readOnly } from '$lib/stores/billing';
import type { RegionList } from '$lib/sdk/billing';
import { onMount, type ComponentType } from 'svelte';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
@@ -40,12 +39,13 @@
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import CreateProjectCloud from './createProjectCloud.svelte';
import { regions as regionsStore } from '$routes/(console)/organization-[organization]/store';
export let data;
let addOrganization = false;
let showCreate = false;
let showCreateProjectCloud = false;
let addOrganization = false;
async function allServiceDisabled(project: Models.Project) {
let disabled = true;
@@ -130,21 +130,18 @@
}
};
let regions: RegionList;
onMount(async () => {
if (isCloud) {
regions = await sdk.forConsole.billing.listRegions();
const regions = await sdk.forConsole.billing.listRegions();
regionsStore.set(regions);
checkPricingRefAndRedirect(page.url.searchParams);
}
const searchParams = page.url.searchParams;
if (searchParams.has('create-project')) {
handleCreateProject();
}
});
function findRegion(project: Models.Project & { region?: string }) {
return regions.regions.find((region) => region.$id === project?.region);
function findRegion(project: Models.Project) {
return $regionsStore?.regions?.find(
(region) => region.$id === (project as Models.Project & { region: string }).region
);
}
</script>
@@ -211,7 +208,7 @@
<Badge variant="secondary" content={`+${project.platforms.length - 3}`} />
{/if}
<svelte:fragment slot="icons">
{#if isCloud && regions}
{#if isCloud && $regionsStore?.regions}
{@const region = findRegion(project)}
<span class="u-color-text-gray u-medium u-line-height-2">
{region?.name}
@@ -243,5 +240,5 @@
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
{#if showCreateProjectCloud}
<CreateProjectCloud bind:showCreateProjectCloud regions={regions.regions} />
<CreateProjectCloud bind:showCreateProjectCloud regions={$regionsStore.regions} />
{/if}
@@ -1,7 +1,6 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { currentPlan, organization } from '$lib/stores/organization';
import BudgetAlert from './budgetAlert.svelte';
import { organization } from '$lib/stores/organization';
import BudgetCap from './budgetCap.svelte';
import PlanSummary from './planSummary.svelte';
import BillingAddress from './billingAddress.svelte';
@@ -19,8 +18,10 @@
import RetryPaymentModal from './retryPaymentModal.svelte';
import { selectedInvoice, showRetryModal } from './store';
import { Button } from '$lib/elements/forms';
import { goto } from '$app/navigation';
import { Alert, Typography } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { base } from '$app/paths';
export let data;
@@ -51,10 +52,21 @@
await confirmPayment(
$organization.$id,
invoice.clientSecret,
$organization.paymentMethodId
$organization.paymentMethodId,
`${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
);
}
if (
page.url.searchParams.has('type') &&
page.url.searchParams.get('type') === 'validate-invoice'
) {
const invoiceId = page.url.searchParams.get('invoice');
await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoiceId);
invalidate(Dependencies.INVOICES);
invalidate(Dependencies.ORGANIZATION);
}
if (
page.url.searchParams.has('invoice') &&
page.url.searchParams.get('type') === 'retry'
@@ -108,24 +120,22 @@
{/if}
{#if $organization?.billingPlanDowngrade}
<Alert.Inline status="info">
Your organization will change to a {tierToPlan($organization?.billingPlanDowngrade)
.name} plan once your current billing cycle ends and your invoice is paid on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
Your organization has changed to {tierToPlan($organization?.billingPlanDowngrade).name} plan.
You will continue to have access to {tierToPlan($organization?.billingPlan).name} plan features
until your billing period ends on {toLocaleDate($organization.billingNextInvoiceDate)}.
</Alert.Inline>
{/if}
<Typography.Title>Billing</Typography.Title>
<PlanSummary
creditList={data?.creditList}
members={data?.members}
currentPlan={$currentPlan}
invoices={data?.invoices.invoices} />
currentPlan={data?.aggregationBillingPlan}
currentAggregation={data?.billingAggregation}
currentInvoice={data?.billingInvoice} />
<PaymentHistory />
<PaymentMethods />
<BillingAddress billingAddress={data?.billingAddress} />
<TaxId />
<BudgetCap />
<BudgetAlert />
<AvailableCredit />
</Container>
@@ -4,7 +4,6 @@ import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { Query } from '@appwrite.io/console';
export const load: PageLoad = async ({ parent, depends }) => {
const { organization, scopes } = await parent();
@@ -25,25 +24,47 @@ export const load: PageLoad = async ({ parent, depends }) => {
.catch(() => null)
: null;
const [paymentMethods, addressList, aggregationList, billingAddress, creditList, invoices] =
/**
* Needed to keep this out of Promise.all, as when organization is
* initially created, these might return 404
* - can be removed later once that is fixed in back-end
*/
let billingAggregation = null;
try {
billingAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
(organization as Organization)?.billingAggregationId
);
} catch (e) {
// ignore error
}
let billingInvoice = null;
try {
billingInvoice = await sdk.forConsole.billing.getInvoice(
organization.$id,
(organization as Organization)?.billingInvoiceId
);
} catch (e) {
// ignore error
}
const [paymentMethods, addressList, billingAddress, creditList, aggregationBillingPlan] =
await Promise.all([
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses(),
sdk.forConsole.billing.listAggregation(organization.$id),
billingAddressPromise,
sdk.forConsole.billing.listCredits(organization.$id),
sdk.forConsole.billing.listInvoices(organization.$id, [
Query.limit(1),
Query.equal('from', organization.billingCurrentInvoiceDate)
])
sdk.forConsole.billing.getPlan(billingAggregation?.plan ?? organization.billingPlan)
]);
return {
paymentMethods,
addressList,
aggregationList,
billingAddress,
aggregationBillingPlan,
creditList,
invoices
billingAggregation,
billingInvoice
};
};
@@ -14,6 +14,8 @@
import { IconTrash } from '@appwrite.io/pink-icons-svelte';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
export let alertsEnabled = false;
let search: string;
let selectedAlert: number;
let alerts: number[] = [];
@@ -72,7 +74,8 @@
}
}
$: isButtonDisabled = symmetricDifference(alerts, $organization.budgetAlerts).length === 0;
$: isButtonDisabled =
symmetricDifference(alerts, $organization.budgetAlerts).length === 0 || !alertsEnabled;
</script>
<CardGrid>
@@ -91,6 +94,11 @@
Upgrade to a Pro plan to manage when you receive billing alerts for your
organization.
</Alert.Inline>
{:else if !$currentPlan.budgetCapEnabled}
<Alert.Inline status="info" title="Budget cap disabled">
Budget caps are not supported on your current plan. For more information, please
reach out to your customer success manager.
</Alert.Inline>
{:else}
{#if alerts.length >= 4}
<Alert.Inline status="info">
@@ -10,13 +10,14 @@
import { sdk } from '$lib/stores/sdk';
import { Alert, Link } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import BudgetAlert from './budgetAlert.svelte';
let capActive = false;
let budget: number;
onMount(() => {
budget = $organization?.billingBudget;
capActive = !!$organization?.billingBudget;
capActive = $organization?.billingBudget !== null;
});
async function updateBudget() {
@@ -26,7 +27,7 @@
budget,
$organization.budgetAlerts
);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
isHtml: true,
@@ -45,7 +46,7 @@
}
$: if (!capActive) {
budget = 0;
budget = null;
}
</script>
@@ -64,6 +65,11 @@
target="_blank"
rel="noopener noreferrer">view our pricing guide.</Link.Anchor>
</Alert.Inline>
{:else if !$currentPlan.budgetCapEnabled}
<Alert.Inline status="info" title="Budget cap disabled">
Budget caps are not supported on your current plan. For more information, please
reach out to your customer success manager.
</Alert.Inline>
{:else}
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
@@ -105,3 +111,5 @@
</svelte:fragment>
</CardGrid>
</Form>
<BudgetAlert alertsEnabled={capActive && budget > 0} />
@@ -0,0 +1,42 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { organization } from '$lib/stores/organization';
import { Dependencies } from '$lib/constants';
import { invalidate } from '$app/navigation';
import { tierToPlan } from '$lib/stores/billing';
import { toLocaleDate } from '$lib/helpers/date';
export let showCancel = false;
let error: string = null;
async function cancelDowngrade() {
try {
await sdk.forConsole.billing.cancelDowngrade($organization.$id);
await invalidate(Dependencies.ORGANIZATION);
showCancel = false;
addNotification({
type: 'success',
message: `${$organization.name} plan change has been cancelled.`
});
} catch (e) {
error = e.message;
}
}
</script>
<Modal title="Cancel plan change" onSubmit={cancelDowngrade} bind:show={showCancel} bind:error>
<p>
Your organization is set to change to <strong>
{tierToPlan($organization?.billingPlanDowngrade).name}</strong>
plan on <strong> {toLocaleDate($organization.billingNextInvoiceDate)}</strong>. Are you sure
you want to cancel this change and keep your current plan?
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showCancel = false)}>Keep change</Button>
<Button secondary submit>Cancel change</Button>
</svelte:fragment>
</Modal>
@@ -5,7 +5,7 @@
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Invoice, InvoiceList } from '$lib/sdk/billing';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
@@ -28,8 +28,7 @@
IconExternalLink,
IconRefresh
} from '@appwrite.io/pink-icons-svelte';
// let isLoadingInvoices = true;
import { base } from '$app/paths';
let offset = 0;
let invoiceList: InvoiceList = {
@@ -38,7 +37,6 @@
};
const limit = 5;
const endpoint = getApiEndpoint();
onMount(request);
@@ -47,11 +45,13 @@
invoiceList = await sdk.forConsole.billing.listInvoices(page.params.organization, [
Query.limit(limit),
Query.offset(offset),
Query.notEqual('from', $organization.billingCurrentInvoiceDate),
Query.notEqual('status', 'pending'),
Query.orderDesc('$createdAt')
]);
// isLoadingInvoices = false;
}
$: if (page.url.searchParams.get('type') === 'validate-invoice') {
window.history.replaceState({}, '', page.url.pathname);
request();
}
function retryPayment(invoice: Invoice) {
@@ -135,12 +135,12 @@
<ActionMenu.Item.Anchor
leadingIcon={IconExternalLink}
external
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
View invoice
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Anchor
leadingIcon={IconDownload}
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
href={`${base}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
Download PDF
</ActionMenu.Item.Anchor>
{#if status === 'overdue' || status === 'failed'}
@@ -3,14 +3,13 @@
import { CardGrid } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { plansInfo, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import type { CreditList, Invoice, Plan } from '$lib/sdk/billing';
import type { Aggregation, CreditList, Invoice, Plan } from '$lib/sdk/billing';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import { type Models } from '@appwrite.io/console';
import {
Accordion,
Card,
@@ -21,14 +20,15 @@
Typography
} from '@appwrite.io/pink-svelte';
import { IconInfo, IconTag } from '@appwrite.io/pink-icons-svelte';
import CancelDowngradeModel from './cancelDowngradeModal.svelte';
export let invoices: Array<Invoice>;
export let members: Models.MembershipList;
export let currentPlan: Plan;
export let creditList: CreditList;
export let currentInvoice: Invoice | undefined = undefined;
export let currentAggregation: Aggregation | undefined = undefined;
let showCancel: boolean = false;
const currentInvoice: Invoice | undefined = invoices.length > 0 ? invoices[0] : undefined;
const extraMembers = members.total > 1 ? members.total - 1 : 0;
const availableCredit = creditList.available;
const today = new Date();
const isTrial =
@@ -60,15 +60,13 @@
exclude accumulated credits and applicable taxes.
<svelte:fragment slot="aside">
<p class="text u-bold">
Billing period: {toLocaleDate($organization?.billingCurrentInvoiceDate)} - {toLocaleDate(
$organization?.billingNextInvoiceDate
)}
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Card.Base variant="secondary" padding="s">
<Layout.Stack>
<Layout.Stack direction="row" justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary">
{tierToPlan($organization?.billingPlan)?.name} plan
{currentPlan.name} plan
</Typography.Text>
<Typography.Text>
{isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
@@ -79,36 +77,40 @@
</Typography.Text>
</Layout.Stack>
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION && extraUsage > 0}
{#if currentPlan.budgeting && extraUsage > 0}
<Accordion
hideDivider
title="Add-ons"
badge={(extraMembers ? extraAddons + 1 : extraAddons).toString()}>
badge={(currentAggregation.additionalMembers > 0
? currentInvoice.usage.length + 1
: currentInvoice.usage.length
).toString()}>
<svelte:fragment slot="end">
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</svelte:fragment>
<Layout.Stack gap="xs">
{#if extraMembers}
{#if currentAggregation.additionalMembers}
<Layout.Stack gap="xxxs">
<Layout.Stack
direction="row"
justifyContent="space-between">
<Typography.Text color="--fgcolor-neutral-primary"
>Additional members</Typography.Text>
<Typography.Text
>{formatCurrency(
extraMembers *
(currentPlan?.addons?.member?.price ?? 0)
)}</Typography.Text>
<Typography.Text>
{formatCurrency(
currentAggregation.additionalMemberAmount
)}
</Typography.Text>
</Layout.Stack>
<Layout.Stack direction="row">
<Typography.Text>{extraMembers}</Typography.Text>
<Typography.Text
>{currentAggregation.additionalMembers}</Typography.Text>
</Layout.Stack>
</Layout.Stack>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
{#if i > 0 || extraMembers}
{#if i > 0 || currentAggregation.additionalMembers}
<Divider />
{/if}
{#if ['storage', 'bandwidth'].includes(excess.name)}
@@ -180,7 +182,7 @@
</Accordion>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && availableCredit > 0}
{#if currentPlan.supportsCredits && availableCredit > 0}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" gap="xxs">
<Icon size="s" icon={IconTag} color="--fgcolor-success" />
@@ -188,7 +190,7 @@
>Credits to be applied</Typography.Text>
</Layout.Stack>
<Typography.Text color="--fgcolor-success">
{formatCurrency(
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</Typography.Text>
@@ -245,17 +247,21 @@
{:else}
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
<Button
text
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
from: 'button',
source: 'billing_tab'
})}>
Change plan
</Button>
{#if $organization?.billingPlanDowngrade !== null}
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
{:else}
<Button
text
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
from: 'button',
source: 'billing_tab'
})}>
Change plan
</Button>
{/if}
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
@@ -265,6 +271,8 @@
</CardGrid>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
--billing-card-border-color: hsl(var(--color-neutral-10));
@@ -15,6 +15,7 @@
import { onMount } from 'svelte';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { formatCurrency } from '$lib/helpers/numbers';
import { base } from '$app/paths';
export let show = false;
export let invoice: Invoice;
@@ -74,8 +75,12 @@
await confirmPayment(
$organization.$id,
clientSecret,
paymentMethodId ? paymentMethodId : $organization.paymentMethodId
paymentMethodId ? paymentMethodId : $organization.paymentMethodId,
`${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
);
await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoice.$id);
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.INVOICES);
@@ -111,9 +116,9 @@
title="Retry payment">
<!-- TODO: format currency -->
<p class="text">
Your payment of <span class="inline-tag">${formatCurrency(invoice.grossAmount)}</span> due
on {toLocaleDate(invoice.dueAt)} has failed. Retry your payment to avoid service interruptions
with your projects.
Your payment of <span class="inline-tag">{formatCurrency(invoice.grossAmount)}</span> due on {toLocaleDate(
invoice.dueAt
)} has failed. Retry your payment to avoid service interruptions with your projects.
</p>
<Button
@@ -17,7 +17,7 @@
async function validateCoupon() {
if (couponData?.status === 'active') return;
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
$addCreditWizardStore.coupon = coupon;
coupon = null;
@@ -0,0 +1,28 @@
<script lang="ts">
import { page } from '$app/stores';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, readOnly, showBudgetAlert } from '$lib/stores/billing';
import { base } from '$app/paths';
$: redirectUrl = `${base}/organization-${$organization.$id}/billing#update-budget`;
</script>
{#if $showBudgetAlert && $organization?.$id && $organization?.billingPlan !== BillingPlan.FREE && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert type="error" title="Budget limit reached">
<svelte:fragment>
This organization has reached its budget limit and is now blocked. To continue using
Appwrite services, update the budget limit.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
<span class="text">View usage</span>
</Button>
<Button secondary fullWidthMobile href={redirectUrl}>
<span class="text">Update limit</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -3,23 +3,19 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
EstimatedTotalBox,
PlanComparisonBox,
SelectPaymentMethod
} from '$lib/components/billing';
import { PlanComparisonBox, PlanSelection, SelectPaymentMethod } from '$lib/components/billing';
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import PlanSelection from '$lib/components/billing/planSelection.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
import { Button, Form, InputSelect, InputTags, InputTextarea } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers.js';
import { Wizard } from '$lib/layout';
import { type Coupon } from '$lib/sdk/billing';
import { plansInfo, tierToPlan } from '$lib/stores/billing';
import { type Coupon, type PaymentList } from '$lib/sdk/billing';
import { isOrganization, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { currentPlan, organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
@@ -33,6 +29,8 @@
Typography
} from '@appwrite.io/pink-svelte';
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
export let data;
@@ -56,10 +54,46 @@
let showCreditModal = false;
let feedbackDowngradeReason: string;
let feedbackMessage: string;
let couponData: Partial<Coupon> = data.coupon;
afterNavigate(({ from }) => {
previousPage = from?.url?.pathname || previousPage;
});
onMount(async () => {
if (page.url.searchParams.has('code')) {
const coupon = page.url.searchParams.get('code');
try {
const response = await sdk.forConsole.billing.getCouponAccount(coupon);
couponData = response;
} catch (e) {
couponData = {
code: null,
status: null,
credits: null
};
}
}
if (page.url.searchParams.has('plan')) {
const plan = page.url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
selectedPlan = plan as BillingPlan;
}
}
if (page.url.searchParams.has('type')) {
const type = page.url.searchParams.get('type');
if (type === 'payment_confirmed') {
const organizationId = page.url.searchParams.get('id');
const invites = page.url.searchParams.get('invites').split(',');
await validate(organizationId, invites);
}
}
if ($currentPlan?.$id === BillingPlan.SCALE) {
selectedPlan = BillingPlan.SCALE;
} else {
selectedPlan = BillingPlan.PRO;
}
});
async function handleSubmit() {
if (isDowngrade) {
@@ -96,14 +130,13 @@
})
});
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
isHtml: true,
message: `
<b>${$organization.name}</b> will change to ${
tierToPlan(selectedPlan).name
} plan at the end of the current billing cycle.`
message: `<b>${$organization.name}</b> plan has been successfully updated.`
});
trackEvent(Submit.OrganizationDowngrade, {
@@ -118,61 +151,88 @@
}
}
async function validate(organizationId: string, invites: string[]) {
try {
let org = await sdk.forConsole.billing.validateOrganization(organizationId, invites);
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
}
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
trackError(e, Submit.OrganizationCreate);
}
}
async function upgrade() {
try {
//Add collaborators
let newCollaborators = [];
if (collaborators?.length) {
newCollaborators = collaborators.filter(
(collaborator) =>
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
);
}
const org = await sdk.forConsole.billing.updatePlan(
data.organization.$id,
selectedPlan,
paymentMethodId,
null
null,
couponData?.code,
newCollaborators,
billingBudget,
taxId ? taxId : null
);
//Add coupon
if (selectedCoupon?.code) {
await sdk.forConsole.billing.addCredit(org.$id, selectedCoupon.code);
trackEvent(Submit.CreditRedeem);
}
//Add budget
if (billingBudget) {
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
}
//Add collaborators
if (collaborators?.length) {
const newCollaborators = collaborators.filter(
(collaborator) =>
!data?.members?.memberships?.find((m) => m.userEmail === collaborator)
if (!isOrganization(org) && org.status == 402) {
let clientSecret = org.clientSecret;
let params = new URLSearchParams();
for (const [key, value] of page.url.searchParams.entries()) {
if (key !== 'type' && key !== 'id') {
params.append(key, value);
}
}
params.append('type', 'payment_confirmed');
params.append('id', org.teamId);
params.append('invites', collaborators.join(','));
params.append('plan', selectedPlan);
await confirmPayment(
'',
clientSecret,
paymentMethodId,
'/console/change-plan?' + params.toString()
);
newCollaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['owner'],
collaborator,
undefined,
undefined,
`${page.url.origin}${base}/invite`
);
await validate(org.teamId, collaborators);
}
if (isOrganization(org)) {
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
}
//Add tax ID
if (taxId) {
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
}
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await goto(previousPage);
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(selectedPlan)?.name
});
} catch (e) {
addNotification({
type: 'error',
@@ -223,14 +283,12 @@
{#if isDowngrade}
{#if selectedPlan === BillingPlan.FREE}
<PlanExcess
tier={BillingPlan.FREE}
members={data?.members?.total ?? 0} />
<PlanExcess tier={BillingPlan.FREE} />
{:else if selectedPlan === BillingPlan.PRO && data.organization.billingPlan === BillingPlan.SCALE && collaborators?.length > 0}
{@const extraMembers = collaborators?.length ?? 0}
{@const price = formatCurrency(
extraMembers *
($plansInfo?.get(selectedPlan)?.addons?.member?.price ?? 0)
($plansInfo?.get(selectedPlan)?.addons?.seats?.price ?? 0)
)}
<Alert.Inline status="error">
<svelte:fragment slot="title">
@@ -37,7 +37,7 @@
showDelete = false;
addNotification({
type: 'success',
message: `${selectedMember.userName} was deleted from ${selectedMember.teamName}`
message: `${selectedMember.userName || 'User'} was deleted from ${selectedMember.teamName}`
});
trackEvent(Submit.MemberDelete);
} catch (e) {
@@ -20,7 +20,8 @@
Icon,
Layout,
Popover,
Table
Table,
Typography
} from '@appwrite.io/pink-svelte';
import DeleteDomainModal from './deleteDomainModal.svelte';
import RetryDomainModal from './retryDomainModal.svelte';
@@ -73,25 +74,27 @@
href={`${base}/organization-${page.params.organization}/domains/domain-${domain.$id}`}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'domain'}
<Link
external
icon
href={`${$protocol}${domain.domain}`}
variant="quiet">
{domain.domain}
</Link>
{:else if column.id === 'registrar'}
{domain.registrar || '-'}
{:else if column.id === 'nameservers'}
{domain.nameservers || '-'}
{:else if column.id === 'expiry_date'}
{domain?.expiry ? toLocaleDateTime(domain.expiry) : '-'}
{:else if column.id === 'renewal'}
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
{:else if column.id === 'auto_renewal'}
{domain?.autoRenewal ? 'On' : 'Off'}
{/if}
<Typography.Text truncate>
{#if column.id === 'domain'}
<Link
external
icon
href={`${$protocol}${domain.domain}`}
variant="quiet">
{domain.domain}
</Link>
{:else if column.id === 'registrar'}
{domain.registrar || '-'}
{:else if column.id === 'nameservers'}
{domain.nameservers || '-'}
{:else if column.id === 'expiry_date'}
{domain?.expiry ? toLocaleDateTime(domain.expiry) : '-'}
{:else if column.id === 'renewal'}
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
{:else if column.id === 'auto_renewal'}
{domain?.autoRenewal ? 'On' : 'Off'}
{/if}
</Typography.Text>
</Table.Cell>
{/each}
@@ -37,10 +37,12 @@
}
$: filesList = files?.length
? Array.from(files).map((file) => {
let f = file as Partial<File> & { removable: boolean };
f.removable = true;
return f;
? Array.from(files).map((f) => {
return {
...f,
extension: f.type,
removable: true
};
})
: [];
</script>
@@ -3,9 +3,9 @@ import { writable } from 'svelte/store';
export const columns = writable<Column[]>([
{ id: 'domain', title: 'Domain', type: 'string', width: { min: 300 } },
{ id: 'registrar', title: 'Registrar', type: 'string' },
{ id: 'nameservers', title: 'Nameservers', type: 'string' },
{ id: 'expiry_date', title: 'Expiry Date', type: 'datetime' },
{ id: 'renewal', title: 'Renewal', type: 'boolean' },
{ id: 'auto_renewal', title: 'Auto Renewal', type: 'boolean' }
{ id: 'registrar', title: 'Registrar', type: 'string', width: { min: 120 } },
{ id: 'nameservers', title: 'Nameservers', type: 'string', width: { min: 120 } },
{ id: 'expiry_date', title: 'Expiry Date', type: 'datetime', width: { min: 120 } },
{ id: 'renewal', title: 'Renewal', type: 'datetime', width: { min: 120 } },
{ id: 'auto_renewal', title: 'Auto Renewal', type: 'boolean', width: { min: 80 } }
]);
@@ -0,0 +1,14 @@
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
// verify invoice exists
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
const endpoint = getApiEndpoint();
return redirect(
302,
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/download`
);
};
@@ -0,0 +1,14 @@
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
// verify invoice exists
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
const endpoint = getApiEndpoint();
return redirect(
302,
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/view`
);
};
@@ -1,19 +1,19 @@
<script lang="ts">
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { AvatarInitials, PaginationWithLimit } from '$lib/components';
import { Button as ConsoleButton } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import Upgrade from '$lib/components/roles/upgrade.svelte';
import { getRoleLabel } from '$lib/stores/billing';
import { addNotification } from '$lib/stores/notifications';
import { newMemberModal, organization } from '$lib/stores/organization';
import { isOwner } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import Delete from '../deleteMember.svelte';
import { base } from '$app/paths';
import { isOwner } from '$lib/stores/roles';
import Edit from './edit.svelte';
import { getRoleLabel } from '$lib/stores/billing';
import {
IconDotsHorizontal,
IconInfo,
@@ -32,9 +32,8 @@
Popover,
ActionMenu
} from '@appwrite.io/pink-svelte';
import Upgrade from '$lib/components/roles/upgrade.svelte';
export let data: PageData;
export let data;
let selectedMember: Models.Membership;
let showDelete = false;
@@ -79,10 +78,10 @@
<Table.Root
let:root
columns={[
{ id: 'name' },
{ id: 'email' },
{ id: 'roles' },
{ id: 'mfa' },
{ id: 'name', width: { min: 200 } },
{ id: 'email', width: { min: 200 } },
{ id: 'roles', width: { min: 200 } },
{ id: 'mfa', width: { min: 80 } },
{ id: 'actions', hide: !$isOwner, width: 40 }
]}>
<svelte:fragment slot="header" let:root>
@@ -107,16 +106,18 @@
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center" gap="s">
<AvatarInitials size="xs" name={member.userName} />
<span class="u-trim">
<Typography.Text truncate>
{member.userName ? member.userName : 'n/a'}
</span>
</Typography.Text>
{#if member.invited && !member.joined}
<Badge type="warning" variant="secondary" content="Pending" size="xs" />
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="email" {root}>
{member.userEmail}
<Typography.Text truncate>
{member.userEmail}
</Typography.Text>
</Table.Cell>
<Table.Cell column="roles" {root}>
{member.roles.map((role) => getRoleLabel(role)).join(', ')}
@@ -0,0 +1,17 @@
<script lang="ts">
import { Alert } from '$lib/components';
import type { EstimationDeleteOrganization } from '$lib/sdk/billing';
import InvoicesTable from './invoicesTable.svelte';
export let estimation: EstimationDeleteOrganization;
</script>
{#if estimation}
{#if estimation.unpaidInvoices?.length > 0}
<Alert type="warning">
This organization has unresolved invoices that must be settled before it can be deleted.
Please review and resolve these invoices to proceed.
</Alert>
<InvoicesTable invoices={estimation.unpaidInvoices} showActions={false} />
{/if}
{/if}
@@ -6,15 +6,17 @@
import { members, organization, organizationList } from '$lib/stores/organization';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { projects } from '../store';
import { toLocaleDate } from '$lib/helpers/date';
import { isCloud } from '$lib/system';
import type { InvoiceList } from '$lib/sdk/billing';
import { formatCurrency } from '$lib/helpers/numbers';
import { tierToPlan } from '$lib/stores/billing';
import { Table, Tabs, Alert } from '@appwrite.io/pink-svelte';
import DeleteOrganizationEstimation from './deleteOrganizationEstimation.svelte';
import { onMount } from 'svelte';
import type { EstimationDeleteOrganization, Invoice, InvoiceList } from '$lib/sdk/billing';
export let showDelete = false;
export let invoices: InvoiceList;
@@ -22,6 +24,7 @@
let selectedTab = 'projects';
let organizationName: string = null;
let estimation: EstimationDeleteOrganization;
async function deleteOrg() {
try {
@@ -44,10 +47,7 @@
showDelete = false;
addNotification({
type: 'success',
message:
$organization.billingPlan === BillingPlan.FREE
? `${$organization.name} has been deleted`
: `${$organization.name} has been flagged for deletion`
message: `${$organization.name} has been deleted`
});
} catch (e) {
error = e.message;
@@ -55,6 +55,8 @@
}
}
onMount(() => getEstimate());
const tabs = [
{
name: 'projects',
@@ -74,6 +76,19 @@
// reset on close.
organizationName = '';
}
async function getEstimate() {
if (isCloud) {
try {
error = '';
estimation = await sdk.forConsole.billing.estimationDeleteOrganization(
$organization.$id
);
} catch (e) {
error = e.message;
}
}
}
</script>
<Modal title="Delete organization" onSubmit={deleteOrg} bind:show={showDelete} bind:error>
@@ -96,54 +111,59 @@
<b>This action is irreversible</b>.
{/if}
</p>
{#if $projects.total > 0}
<Tabs.Root let:root stretch>
{#each tabs as { name, label, total }}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = name)}
active={selectedTab === name}>
{label.desktop} ({total})
</Tabs.Item.Button>
{/each}
</Tabs.Root>
{#if selectedTab === 'projects'}
<Table.Root columns={[{ id: 'projects' }, { id: 'lastUpdated' }]} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="projects" {root}>Projects</Table.Header.Cell>
<Table.Header.Cell column="lastUpdated" {root}>Last updated</Table.Header.Cell>
</svelte:fragment>
{#each $projects.projects as project}
<Table.Row.Base {root}>
<Table.Cell column="projects" {root}>{project.name}</Table.Cell>
<Table.Cell column="lastUpdated" {root}
>{toLocaleDate(project.$updatedAt)}</Table.Cell>
</Table.Row.Base>
{#if estimation && (estimation.unpaidInvoices.length > 0 || estimation.grossAmount > 0)}
<DeleteOrganizationEstimation {estimation} />
{:else}
{#if $projects.total > 0}
<Tabs.Root let:root stretch>
{#each tabs as { name, label, total }}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = name)}
active={selectedTab === name}>
{label.desktop} ({total})
</Tabs.Item.Button>
{/each}
</Table.Root>
{:else}
<Table.Root columns={[{ id: 'member' }, { id: 'lastUpdated' }]} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="member" {root}>Members</Table.Header.Cell>
<Table.Header.Cell column="lastUpdated" {root}>Last updated</Table.Header.Cell>
</svelte:fragment>
{#each $members.memberships as membership}
<Table.Row.Base {root}>
<Table.Cell column="member" {root}>{membership.userName}</Table.Cell>
<Table.Cell column="lastUpdated" {root}
>{toLocaleDate(membership.$updatedAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</Tabs.Root>
{#if selectedTab === 'projects'}
<Table.Root columns={[{ id: 'projects' }, { id: 'lastUpdated' }]} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="projects" {root}>Projects</Table.Header.Cell>
<Table.Header.Cell column="lastUpdated" {root}
>Last updated</Table.Header.Cell>
</svelte:fragment>
{#each $projects.projects as project}
<Table.Row.Base {root}>
<Table.Cell column="projects" {root}>{project.name}</Table.Cell>
<Table.Cell column="lastUpdated" {root}
>{toLocaleDate(project.$updatedAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{:else}
<Table.Root columns={[{ id: 'member' }, { id: 'lastUpdated' }]} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="member" {root}>Members</Table.Header.Cell>
<Table.Header.Cell column="lastUpdated" {root}
>Last updated</Table.Header.Cell>
</svelte:fragment>
{#each $members.memberships as membership}
<Table.Row.Base {root}>
<Table.Cell column="member" {root}>{membership.userName}</Table.Cell>
<Table.Cell column="lastUpdated" {root}
>{toLocaleDate(membership.$updatedAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{/if}
{/if}
<InputText
label={`Confirm the organization name to continue`}
placeholder="Enter {$organization.name} to continue"
id="organization-name"
required
bind:value={organizationName} />
{/if}
<InputText
label={`Confirm the organization name to continue`}
placeholder="Enter {$organization.name} to continue"
id="organization-name"
required
bind:value={organizationName} />
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button
@@ -0,0 +1,137 @@
<script lang="ts">
import type { Invoice } from '$lib/sdk/billing';
import { getApiEndpoint } from '$lib/stores/sdk';
import { selectedInvoice, showRetryModal } from '../billing/store';
import { toLocaleDate } from '$lib/helpers/date';
import { Button } from '$lib/elements/forms';
import { formatCurrency } from '$lib/helpers/numbers';
import { trackEvent } from '$lib/actions/analytics';
import { ActionMenu, Badge, Icon, Link, Popover, Table } from '@appwrite.io/pink-svelte';
import {
IconDotsHorizontal,
IconDownload,
IconExternalLink,
IconRefresh
} from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
const endpoint = getApiEndpoint();
export let invoices: Invoice[];
export let showActions = true;
function retryPayment(invoice: Invoice) {
$selectedInvoice = invoice;
$showRetryModal = true;
}
</script>
<Table.Root
columns={[
{ id: 'date', width: { min: 100 } },
{ id: 'status', width: { min: 80 } },
{ id: 'amount_due', width: { min: 100 } },
showActions && { id: 'action', width: 40 }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Due Date</Table.Header.Cell>
<Table.Header.Cell {root}>Status</Table.Header.Cell>
<Table.Header.Cell {root}>Amount Due</Table.Header.Cell>
{#if showActions}
<Table.Header.Cell {root} />
{/if}
</svelte:fragment>
{#each invoices as invoice, i}
{@const status = invoice.status}
<Table.Row.Base {root}>
<Table.Cell column="date" {root}>
{toLocaleDate(invoice.dueAt)}
</Table.Cell>
<Table.Cell column="status" {root}>
{#if invoice?.lastError}
<Popover let:toggle>
<div>
<Badge
on:click={toggle}
variant="secondary"
type={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'
? 'error'
: status === 'paid' || status === 'succeeded'
? 'success'
: 'warning'}
content={status === 'requires_authentication' ? 'failed' : status}>
</Badge>
</div>
<svelte:fragment slot="list">
<p>
The scheduled payment has failed.
<Link.Button
on:click={() => {
retryPayment(invoice);
}}
>Try again
</Link.Button>
.
</p>
</svelte:fragment>
</Popover>
{:else}
<Badge
variant="secondary"
type={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'
? 'error'
: status === 'paid' || status === 'succeeded'
? 'success'
: 'warning'}
content={status === 'requires_authentication' ? 'failed' : status}>
</Badge>
{/if}
</Table.Cell>
<Table.Cell column="due" {root}>
{formatCurrency(invoice.grossAmount)}
</Table.Cell>
{#if showActions}
<Table.Cell {root}>
<Popover let:toggle>
<Button text ariaLabel="More options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="tooltip">
<ActionMenu.Root>
<ActionMenu.Item.Anchor
trailingIcon={IconExternalLink}
external
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/view`}>
View invoice
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Anchor
trailingIcon={IconDownload}
href={`${endpoint}/organizations/${page.params.organization}/invoices/${invoice.$id}/download`}>
Download PDF
</ActionMenu.Item.Anchor>
{#if status === 'overdue' || status === 'failed'}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
on:click={() => {
retryPayment(invoice);
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</Table.Cell>
{/if}
</Table.Row.Base>
{/each}
</Table.Root>
@@ -1,5 +1,16 @@
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import { derived } from 'svelte/store';
import { derived, writable } from 'svelte/store';
import type { RegionList } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
export const regions = writable<RegionList | undefined>(undefined);
export const regionFlagUrls = derived(regions, ($regions) => {
if (!$regions?.regions?.length) return [];
return $regions?.regions?.map((region) => {
return `${sdk.forConsole.client.config.endpoint}/avatars/flags/${region.flag}?width=80&height=60&quality=100&mode=admin`;
});
});
export const projects = derived(page, ($page) => $page.data?.projects as Models.ProjectList);
@@ -0,0 +1,29 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import {
billingLimitOutstandingInvoice,
hideBillingHeaderRoutes,
readOnly,
teamStatusReadonly
} from '$lib/stores/billing';
import { base } from '$app/paths';
$: redirectUrl = `${base}/organization-${$organization?.$id}/billing#payment-history`;
</script>
{#if $organization?.$id && $organization?.status === teamStatusReadonly && $organization?.remarks === billingLimitOutstandingInvoice && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert type="error" title="Access restricted">
<svelte:fragment>
Your organizations access to resources has been restricted due to unpaid invoices.
Payment is required to restore access.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button secondary fullWidthMobile href={redirectUrl}>
<span class="text">View invoices</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -1,6 +1,9 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { Card, CardGrid, ProgressBarBig } from '$lib/components';
import { BarChart, Legend } from '$lib/charts';
import { Button } from '$lib/elements/forms';
import { bytesToSize, humanFileSize, mbSecondsToGBHours } from '$lib/helpers/sizeConvertion';
import {
getServiceLimit,
showUsageRatesModal,
@@ -8,9 +11,6 @@
upgradeURL
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { Button } from '$lib/elements/forms';
import { bytesToSize, humanFileSize, mbSecondsToGBHours } from '$lib/helpers/sizeConvertion';
import { BarChart, Legend } from '$lib/charts';
import ProjectBreakdown from './ProjectBreakdown.svelte';
import { formatNum } from '$lib/helpers/string';
import { accumulateFromEndingTotal, total } from '$lib/layout/usage.svelte';
@@ -32,8 +32,20 @@
$: projects = (data.organizationUsage as OrganizationUsage).projects;
$: legendData = [
{ name: 'Reads', value: data.organizationUsage.databasesReadsTotal },
{ name: 'Writes', value: data.organizationUsage.databasesWritesTotal }
{
name: 'Reads',
value: data.organizationUsage.databasesReads.reduce(
(sum, singleDay) => sum + singleDay.value,
0
)
},
{
name: 'Writes',
value: data.organizationUsage.databasesWrites.reduce(
(sum, singleDay) => sum + singleDay.value,
0
)
}
];
</script>
@@ -158,8 +170,9 @@
{
name: 'Users',
data: accumulateFromEndingTotal(
data.usersUsageToDate,
data.organizationUsage.usersTotal
data.organizationUsage.users,
data.organizationUsage.usersTotal,
new Date()
)
}
]} />
@@ -213,7 +226,7 @@
}
]} />
<Legend {legendData} />
<Legend {legendData} numberFormat="abbreviate" decimalsForAbbreviate={2} />
{#if projects?.length > 0}
<ProjectBreakdown
@@ -233,10 +246,63 @@
</svelte:fragment>
</CardGrid>
<CardGrid gap="none">
<svelte:fragment slot="title">Image transformations</svelte:fragment>
Calculated for all functions that are executed in all projects in your organization.
<p class="text">
The total number of unique image transformations across all projects in your
organization. <a
href="https://appwrite.io/docs/advanced/platform/image-transformations"
class="link">Learn more</a
>.
</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.imageTransformationsTotal}
{@const current = data.organizationUsage.imageTransformationsTotal}
<ProgressBarBig
currentUnit="Transformations"
currentValue={formatNum(current)}
progressValue={current}
showBar={false} />
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
}
}
}}
series={[
{
name: 'Image transformations',
data: [
...(data.organizationUsage.imageTransformations ?? []).map((e) => [
e.date,
e.value
])
]
}
]} />
{#if projects?.length > 0}
<ProjectBreakdown {projects} metric="imageTransformations" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;"></span>
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid gap="none">
<svelte:fragment slot="title">Executions</svelte:fragment>
Calculated for all functions that are executed in all projects in your organization.
<svelte:fragment slot="aside">
{#if data.organizationUsage.executions}
{@const current = data.organizationUsage.executionsTotal}
@@ -1,8 +1,8 @@
import type { Invoice } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { Query, type Models } from '@appwrite.io/console';
import type { PageLoad } from './$types';
import { type Organization } from '$lib/stores/organization';
import type { Invoice } from '$lib/sdk/billing';
export const load: PageLoad = async ({ params, parent }) => {
const { invoice } = params;
@@ -33,7 +33,9 @@ export const load: PageLoad = async ({ params, parent }) => {
databasesReads: null,
databasesWrites: null,
databasesReadsTotal: null,
databasesWritesTotal: null
databasesWritesTotal: null,
imageTransformations: null,
imageTransformationsTotal: null
}
};
}
@@ -51,7 +53,7 @@ export const load: PageLoad = async ({ params, parent }) => {
sdk.forConsole.billing.listInvoices(org.$id, [Query.orderDesc('from')]),
sdk.forConsole.billing.listUsage(params.organization, startDate, endDate),
sdk.forConsole.teams.listMemberships(params.organization, [Query.limit(100)]),
sdk.forConsole.billing.getPlan(org.$id)
sdk.forConsole.billing.getOrganizationPlan(org.$id)
]);
const projectNames: { [key: string]: Models.Project } = {};
@@ -78,15 +80,12 @@ export const load: PageLoad = async ({ params, parent }) => {
}
}
const usersUsageToDate = usage.users.filter((user) => new Date(user.date) < new Date());
return {
organizationUsage: usage,
projectNames,
invoices,
currentInvoice,
organizationMembers,
plan,
usersUsageToDate
plan
};
};
@@ -1,12 +1,11 @@
<script lang="ts">
import type { PageData } from './$types';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { base } from '$app/paths';
import { canSeeProjects } from '$lib/stores/roles';
import { onMount } from 'svelte';
import { Accordion, Table } from '@appwrite.io/pink-svelte';
import { base } from '$app/paths';
type Metric =
| 'users'
@@ -15,13 +14,14 @@
| 'executions'
| 'authPhoneTotal'
| 'databasesReads'
| 'databasesWrites';
| 'databasesWrites'
| 'imageTransformations';
type Estimate = 'authPhoneEstimate';
type DatabaseOperationMetric = Extract<Metric, 'databasesReads' | 'databasesWrites'>;
export let data: PageData;
export let data;
export let projects: OrganizationUsage['projects'];
export let metric: Metric | undefined = undefined;
export let estimate: Estimate | undefined = undefined;
@@ -84,6 +84,7 @@
}
switch (metric) {
case 'imageTransformations':
case 'authPhoneTotal':
return formatNumberWithCommas(value);
case 'executions':
@@ -40,7 +40,7 @@
You can add unlimited organization members on the {tierToPlan(
$organization.billingPlan
).name} plan {$organization.billingPlan === BillingPlan.PRO
? `for ${formatCurrency(plan?.addons?.member?.price)} each per billing period.`
? `for ${formatCurrency(plan.addons.seats.price)} each per billing period.`
: '.'}
</svelte:fragment>
</Tooltip>
@@ -1,4 +1,3 @@
import type { RegionList } from '$lib/sdk/billing';
import { writable } from 'svelte/store';
export const createProject = writable<{
@@ -10,5 +9,3 @@ export const createProject = writable<{
name: 'Appwrite project',
region: 'fra'
});
export const regions = writable<RegionList | undefined>(undefined);
@@ -8,6 +8,9 @@ import { isCloud } from '$lib/system';
import type { Organization } from '$lib/stores/organization';
import { defaultRoles, defaultScopes } from '$lib/constants';
import type { Plan } from '$lib/sdk/billing';
import { get } from 'svelte/store';
import { headerAlert } from '$lib/stores/headerAlert';
import PaymentFailed from '$lib/components/billing/alerts/paymentFailed.svelte';
export const load: LayoutLoad = async ({ params, depends }) => {
depends(Dependencies.PROJECT);
@@ -29,12 +32,20 @@ export const load: LayoutLoad = async ({ params, depends }) => {
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
if (isCloud) {
currentPlan = await sdk.forConsole.billing.getPlan(project.teamId);
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(project.teamId);
const res = await sdk.forConsole.billing.getRoles(project.teamId);
roles = res.roles;
scopes = res.scopes;
if (scopes.includes('billing.read')) {
await failedInvoice.load(project.teamId);
if (get(failedInvoice)) {
headerAlert.add({
show: true,
component: PaymentFailed,
id: 'paymentFailed',
importance: 1
});
}
}
}
@@ -42,7 +42,7 @@
}
</script>
<Modal title="Create team" {error} size="l" bind:show={showCreate} onSubmit={create}>
<Modal title="Create team" {error} size="m" bind:show={showCreate} onSubmit={create}>
<InputText
id="name"
label="Name"
@@ -107,24 +107,24 @@
alt="Mock Numbers Example" />
{/if}
</div>
<div class="is-not-mobile u-width-full-line u-height-100-percent">
{#if $app.themeInUse === 'dark'}
<img
src={MockNumbersDark}
width="266"
height="171"
class="u-image-object-fit-contain u-block u-only-dark u-width-full-line u-height-100-percent"
style:object-position="top"
alt="Mock Numbers Example" />
{:else}
<img
src={MockNumbersLight}
width="266"
height="171"
class="u-image-object-fit-contain u-only-light u-width-full-line u-height-100-percent"
style:object-position="top"
alt="Mock Numbers Example" />
{/if}
<div
class="is-not-mobile"
style:background-color="var(--bgcolor-neutral-default)">
<Layout.Stack justifyContent="center" direction="row">
{#if $app.themeInUse === 'dark'}
<img
src={MockNumbersDark}
width="266"
style:object-position="top"
alt="Mock Numbers Example" />
{:else}
<img
src={MockNumbersLight}
width="266"
style:object-position="top"
alt="Mock Numbers Example" />
{/if}
</Layout.Stack>
</div>
</svelte:fragment>
<svelte:fragment slot="title">{emptyStateTitle}</svelte:fragment>
@@ -8,6 +8,7 @@
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
import { tick } from 'svelte';
let passwordHistory = $project?.authPasswordHistory < 1 ? 5 : $project?.authPasswordHistory;
let passwordHistoryEnabled = ($project?.authPasswordHistory ?? 0) !== 0;
@@ -34,6 +35,14 @@
trackError(error, Submit.AuthPasswordHistoryUpdate);
}
}
let maxPasswordInputField: InputNumber | null = null;
$: if (passwordHistoryEnabled && maxPasswordInputField) {
tick().then(() => {
maxPasswordInputField.addInputFocus();
});
}
</script>
<Form onSubmit={updatePasswordHistoryLimit}>
@@ -13,6 +13,8 @@
import { project } from '../../store';
import { base } from '$app/paths';
import { Avatar, Badge, Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
const projectId = page.params.project;
let showProvider = false;
@@ -30,6 +32,7 @@
method: box.method,
value: box.value
});
invalidate(Dependencies.PROJECT);
} catch (error) {
box.value = !box.value;
addNotification({
@@ -114,7 +117,7 @@
this={oAuthProvider.component}
bind:provider={selectedProvider}
bind:show={showProvider}
on:close={() => {
onclose={() => {
selectedProvider = null;
showProvider = false;
}} />

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