mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'feat-pink-v2' into 'file-tokens'.
This commit is contained in:
@@ -42,6 +42,7 @@ jobs:
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
|
||||
"SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}"
|
||||
"SENTRY_RELEASE=${{ github.event.release.tag_name }}"
|
||||
publish-cloud-stage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+4
-1
@@ -6,6 +6,7 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm i -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.0.0 --activate
|
||||
|
||||
ADD ./package.json /app/package.json
|
||||
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml
|
||||
@@ -24,12 +25,14 @@ ARG PUBLIC_APPWRITE_ENDPOINT
|
||||
ARG PUBLIC_GROWTH_ENDPOINT
|
||||
ARG PUBLIC_STRIPE_KEY
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
ARG SENTRY_RELEASE
|
||||
|
||||
ENV PUBLIC_APPWRITE_ENDPOINT=$PUBLIC_APPWRITE_ENDPOINT
|
||||
ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
|
||||
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
|
||||
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
ENV NODE_OPTIONS=--max_old_space_size=8192
|
||||
|
||||
RUN pnpm run 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
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+130
-19
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,16 +8,23 @@
|
||||
<script lang="ts">
|
||||
import { Colors } from '$lib/charts/config';
|
||||
import { Status } from '$lib/components';
|
||||
import { formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
import { abbreviateNumber, formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
|
||||
export let legendData: LegendData[] = [];
|
||||
export let decimalsForAbbreviate: number = 1;
|
||||
export let numberFormat: 'comma' | 'abbreviate' = 'comma';
|
||||
|
||||
let colors = Object.values(Colors);
|
||||
</script>
|
||||
|
||||
<div class="u-flex u-cross-center u-gap-16">
|
||||
{#each legendData as { name, value }, index}
|
||||
{@const formattedValue = typeof value === 'number' ? formatNumberWithCommas(value) : value}
|
||||
{@const formattedValue =
|
||||
typeof value === 'number'
|
||||
? numberFormat === 'comma'
|
||||
? formatNumberWithCommas(value)
|
||||
: abbreviateNumber(value, decimalsForAbbreviate)
|
||||
: value}
|
||||
<Status status="none" statusIconStyle="background-color: {colors[index % colors.length]}">
|
||||
{name} ({formattedValue})
|
||||
</Status>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { AvatarInitials, Code, LoadingDots, SvgIcon } from '$lib/components';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { useCompletion } from 'ai/svelte';
|
||||
import { useCompletion } from '@ai-sdk/svelte';
|
||||
import { subPanels } from '../subPanels';
|
||||
|
||||
import { isLanguage, type Language } from '$lib/components/code.svelte';
|
||||
@@ -20,7 +20,8 @@
|
||||
headers: {
|
||||
'x-appwrite-project': 'console'
|
||||
},
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
streamProtocol: 'text'
|
||||
});
|
||||
|
||||
const examples = [
|
||||
@@ -179,7 +180,7 @@
|
||||
{#if $isLoading || answer}
|
||||
<div class="content">
|
||||
<div class="u-flex u-gap-8 u-cross-center">
|
||||
<div class="avatar is-size-x-small">{getInitials($user.name)}</div>
|
||||
<div class="avatar is-size-x-small">{getInitials($user.name || $user.email)}</div>
|
||||
<p class="u-opacity-75">{previousQuestion}</p>
|
||||
</div>
|
||||
<div class="u-flex u-gap-8 u-margin-block-start-24">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { activeHeaderAlert } from '$routes/(console)/store';
|
||||
@@ -29,7 +29,7 @@
|
||||
secondary
|
||||
fullWidthMobile
|
||||
class="u-line-height-1"
|
||||
href={`${base}/apply-credit?code=appw50&org=${$organization.$id}`}
|
||||
href={`${base}/apply-credit?code=${NEW_DEV_PRO_UPGRADE_COUPON}&org=${$organization.$id}`}
|
||||
on:click={() => {
|
||||
trackEvent(Click.CreditsRedeemClick, {
|
||||
from: 'button',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { failedInvoice } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
|
||||
$: isOnProjects = $page.route.id.includes('project-[project]');
|
||||
</script>
|
||||
|
||||
{#if $failedInvoice && $failedInvoice.teamId === $organization.$id && isOnProjects}
|
||||
<HeaderAlert type="error" title="A scheduled payment for {$organization.name} failed">
|
||||
To avoid service disruptions in your projects, please verify your payment details and try
|
||||
again.
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button
|
||||
href={`${base}/organization-${$organization?.$id}/billing`}
|
||||
secondary
|
||||
fullWidthMobile>
|
||||
<span class="text">Go to billing</span>
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
{/if}
|
||||
@@ -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,8 +2,9 @@ export { default as PaymentBoxes } from './paymentBoxes.svelte';
|
||||
export { default as CouponInput } from './couponInput.svelte';
|
||||
export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
|
||||
export { default as UsageRates } from './usageRates.svelte';
|
||||
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
|
||||
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
|
||||
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
|
||||
export { default as CreditsApplied } from './creditsApplied.svelte';
|
||||
export { default as PlanSelection } from './planSelection.svelte';
|
||||
export { default as EstimatedTotal } from './estimatedTotal.svelte';
|
||||
export { default as SelectPlan } from './selectPlan.svelte';
|
||||
|
||||
@@ -2,19 +2,17 @@
|
||||
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { abbreviateNumber } from '$lib/helpers/numbers';
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
import { onMount } from 'svelte';
|
||||
import type { OrganizationUsage } from '$lib/sdk/billing';
|
||||
import type { Aggregation } from '$lib/sdk/billing';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
export let tier: Tier;
|
||||
export let members: number;
|
||||
|
||||
const plan = $plansInfo?.get(tier);
|
||||
let excess: {
|
||||
@@ -24,16 +22,15 @@
|
||||
executions?: number;
|
||||
members?: number;
|
||||
} = null;
|
||||
let usage: OrganizationUsage = null;
|
||||
let aggregation: Aggregation = null;
|
||||
let showExcess = false;
|
||||
|
||||
onMount(async () => {
|
||||
usage = await sdk.forConsole.billing.listUsage(
|
||||
aggregation = await sdk.forConsole.billing.getAggregation(
|
||||
$organization.$id,
|
||||
$organization.billingCurrentInvoiceDate,
|
||||
new Date().toISOString()
|
||||
$organization.billingAggregationId
|
||||
);
|
||||
excess = calculateExcess(usage, plan, members);
|
||||
excess = calculateExcess(aggregation, plan);
|
||||
showExcess = Object.values(excess).some((value) => value > 0);
|
||||
});
|
||||
</script>
|
||||
@@ -41,22 +38,16 @@
|
||||
{#if showExcess}
|
||||
<Alert.Inline
|
||||
status="error"
|
||||
title={`Your ${tierToPlan($organization.billingPlan).name} plan subscription will end on ${toLocaleDate(
|
||||
title={` Your organization will switch to ${tierToPlan(BillingPlan.FREE).name} plan on ${toLocaleDate(
|
||||
$organization.billingNextInvoiceDate
|
||||
)}`}>
|
||||
Following payment of your final invoice, your organization will switch to the {tierToPlan(
|
||||
BillingPlan.FREE
|
||||
).name} plan. {#if excess?.members > 0}All team members except the owner will be removed on
|
||||
that date.{/if} Service disruptions may occur unless resource usage is reduced.
|
||||
<!-- Any executions, bandwidth, or messaging usage will be reset at that time. -->
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button
|
||||
text
|
||||
external
|
||||
href="https://appwrite.io/docs/advanced/platform/free#reaching-resource-limits">
|
||||
Learn more
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
You will retain access to {tierToPlan($organization.billingPlan).name} plan features until your
|
||||
billing period ends. After that,
|
||||
{#if excess?.members > 0}<span class="u-bold">
|
||||
all team members except the owner will be removed,</span>
|
||||
{:else}
|
||||
<span class="u-bold">your organization will be limited to Free plan resources,</span>
|
||||
{/if} and service disruptions may occur if usage exceeds Free plan limits.
|
||||
</Alert.Inline>
|
||||
|
||||
<Table.Root columns={3} let:root>
|
||||
@@ -73,7 +64,7 @@
|
||||
{#if excess?.members}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell {root}>Organization members</Table.Cell>
|
||||
<Table.Cell {root}>{plan.members} members</Table.Cell>
|
||||
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
|
||||
<Table.Cell {root}>
|
||||
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
|
||||
<span class="icon-arrow-up"></span>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { LabelCard } from '..';
|
||||
|
||||
export let billingPlan: string;
|
||||
export let anyOrgFree = false;
|
||||
export let isNewOrg = false;
|
||||
let classes: string = '';
|
||||
export { classes as class };
|
||||
</script>
|
||||
|
||||
{#if billingPlan}
|
||||
<ul class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-8 {classes}">
|
||||
{#each $plansInfo.values() as plan}
|
||||
<li>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
disabled={(plan.$id === BillingPlan.FREE && anyOrgFree) || !plan.selfService}
|
||||
value={plan.$id}
|
||||
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
|
||||
tooltipText={plan.$id === BillingPlan.FREE
|
||||
? 'You are limited to 1 Free organization per account.'
|
||||
: ''}
|
||||
padding={1.5}>
|
||||
<svelte:fragment slot="custom" let:disabled>
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
|
||||
class:u-opacity-50={disabled}>
|
||||
<h4 class="body-text-2 u-bold">
|
||||
{plan.name}
|
||||
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
|
||||
<span class="inline-tag">Current plan</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="u-color-text-offline u-small">
|
||||
{plan.desc}
|
||||
</p>
|
||||
<p>
|
||||
{formatCurrency(plan?.price ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</LabelCard>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -43,6 +43,13 @@
|
||||
];
|
||||
|
||||
$: isFree = org.billingPlan === BillingPlan.FREE;
|
||||
|
||||
// equal or above means unlimited!
|
||||
$: getCorrectSeatsCountValue = (count: number): string | number => {
|
||||
// php int max is always larger than js
|
||||
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
|
||||
return exceedsSafeLimit ? 'Unlimited' : count || 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show title="Usage rates">
|
||||
@@ -75,10 +82,10 @@
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>{usage.resource}</Table.Cell>
|
||||
<Table.Cell column="limit" {root}>
|
||||
{plan[usage.id] || 'Unlimited'}
|
||||
{getCorrectSeatsCountValue(plan.addons.seats.limit)}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="rate" {root}>
|
||||
{formatCurrency(plan.addons?.member?.price)}/{usage?.unit}
|
||||
{formatCurrency(plan.addons?.seats?.price)}/{usage?.unit}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{:else}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
async function addCoupon() {
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
// const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
const response = await sdk.forConsole.billing.getCouponAccount(coupon); //TODO: double check that this is the correct method
|
||||
|
||||
if (response.onlyNewOrgs && !isNewOrg) {
|
||||
show = false;
|
||||
@@ -37,6 +38,14 @@
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
}
|
||||
couponData = response;
|
||||
dispatch('validation', couponData);
|
||||
coupon = null;
|
||||
show = false;
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
<Layout.Stack direction="row" justifyContent="flex-start">
|
||||
<Layout.Stack direction="row" justifyContent="flex-start" alignItems="center">
|
||||
<slot name="image" />
|
||||
<div>
|
||||
<slot name="title" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -79,7 +79,9 @@
|
||||
on:cancel|preventDefault
|
||||
{style}>
|
||||
{#if show}
|
||||
<slot close={closeModal} />
|
||||
<div class="content">
|
||||
<slot close={closeModal} />
|
||||
</div>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} />
|
||||
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} type="link" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" generics="T">
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import PaginationInline from './paginationInline.svelte';
|
||||
import Limit from './limit.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
items = [],
|
||||
@@ -10,7 +11,19 @@
|
||||
hidePages = true,
|
||||
hasLimit = false,
|
||||
name = 'items',
|
||||
gap = 's',
|
||||
children
|
||||
}: {
|
||||
items: T[];
|
||||
limit?: number;
|
||||
hideFooter?: boolean;
|
||||
hidePages?: boolean;
|
||||
hasLimit?: boolean;
|
||||
name?: string;
|
||||
gap?:
|
||||
| ('none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl')
|
||||
| undefined;
|
||||
children: Snippet<[T[], number]>;
|
||||
} = $props();
|
||||
|
||||
let total = $derived(items.length);
|
||||
@@ -20,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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export let maxValue: string | undefined = undefined;
|
||||
export let maxUnit: string | undefined = undefined;
|
||||
export let progressValue: number;
|
||||
export let progressMax: number;
|
||||
export let progressMax: number | undefined = undefined;
|
||||
export let showBar = true;
|
||||
export let progressBarData: Array<ProgressbarData> = [];
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<section class="progress-bar">
|
||||
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined && maxValue !== undefined}
|
||||
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined}
|
||||
<div class="u-flex u-flex-vertical">
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<Layout.Stack gap="s" direction="row" alignItems="baseline">
|
||||
@@ -23,14 +23,18 @@
|
||||
{currentValue}
|
||||
</Typography.Title>
|
||||
<Typography.Text>{currentUnit}</Typography.Text>
|
||||
<Typography.Text color="--fgcolor-neutral-tertiary">
|
||||
{maxValue}
|
||||
{maxUnit ? maxUnit : ''}
|
||||
</Typography.Text>
|
||||
{#if maxValue !== undefined}
|
||||
<Typography.Text color="--fgcolor-neutral-tertiary">
|
||||
{maxValue}
|
||||
{maxUnit ? maxUnit : ''}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
<div>
|
||||
<Badge variant="secondary" size="xs" content={`${progress}%`} />
|
||||
</div>
|
||||
{#if progressMax !== undefined}
|
||||
<div>
|
||||
<Badge variant="secondary" size="xs" content={`${progress}%`} />
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let name: string;
|
||||
export let group: string;
|
||||
export let value: string | number | boolean;
|
||||
export let disabled = false;
|
||||
export let padding = 1;
|
||||
export let autofocus = false;
|
||||
export let fullHeight = true;
|
||||
export let borderRadius: 'xsmall' | 'small' | 'medium' | 'large' = 'small';
|
||||
|
||||
@@ -13,9 +16,18 @@
|
||||
medium = '--border-radius-medium',
|
||||
large = '--border-radius-large'
|
||||
}
|
||||
|
||||
let labelReference: HTMLLabelElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (autofocus) {
|
||||
labelReference?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<label
|
||||
bind:this={labelReference}
|
||||
class="box u-cursor-pointer u-flex u-flex-vertical u-gap-16"
|
||||
class:is-allow-focus={!disabled}
|
||||
class:is-disabled={disabled}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const PAGE_LIMIT = 12; // default page limit
|
||||
export const CARD_LIMIT = 6; // default card limit
|
||||
export const INTERVAL = 5 * 60000; // default interval to check for feedback
|
||||
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
|
||||
|
||||
export enum Dependencies {
|
||||
FACTORS = 'dependency:factors',
|
||||
@@ -502,7 +503,8 @@ export enum BillingPlan {
|
||||
PRO = 'tier-1',
|
||||
SCALE = 'tier-2',
|
||||
GITHUB_EDUCATION = 'auto-1',
|
||||
CUSTOM = 'cont-1'
|
||||
CUSTOM = 'cont-1',
|
||||
ENTERPRISE = 'ent-1'
|
||||
}
|
||||
|
||||
export const feedbackDowngradeOptions = [
|
||||
|
||||
@@ -12,13 +12,10 @@
|
||||
|
||||
export function getFlag(country: string, width: number, height: number, quality: number) {
|
||||
if (!isValueOfStringEnum(Flag, country)) return '';
|
||||
let flag = sdk.forConsole.avatars
|
||||
return sdk.forConsole.avatars
|
||||
.getFlag(country, width * 2, height * 2, quality)
|
||||
?.toString();
|
||||
flag?.includes('&project=')
|
||||
? (flag = flag.replace('&project=', '&mode=admin'))
|
||||
: flag + '&mode=admin';
|
||||
return flag;
|
||||
?.toString()
|
||||
?.replace('&project=console', '&mode=admin');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export let selected: string[] = [];
|
||||
export let pageItemsIds: string[] = [];
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
function handleClick(e: CustomEvent) {
|
||||
if (!isHTMLInputElement(e.target)) return;
|
||||
if (e.target.checked) {
|
||||
const set = new Set(selected);
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function getReferrerAndUtmSource() {
|
||||
if (sessionStorage) {
|
||||
let values = {};
|
||||
if (sessionStorage.getItem('utmReferral')) {
|
||||
values = { ...values, utmReferral: sessionStorage.getItem('utmReferral') };
|
||||
}
|
||||
if (sessionStorage.getItem('utmSource')) {
|
||||
values = { ...values, utmSource: sessionStorage.getItem('utmSource') };
|
||||
}
|
||||
if (sessionStorage.getItem('utmMedium')) {
|
||||
values = { ...values, utmMedium: sessionStorage.getItem('utmMedium') };
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -60,13 +60,15 @@
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: tier = tierToPlan($organization?.billingPlan)?.name;
|
||||
// these can be organization level limitations as well.
|
||||
// we need to migrate this sometime later, but soon!
|
||||
$: hasProjectLimitation =
|
||||
checkForProjectLimitation(serviceId) && $organization?.billingPlan === BillingPlan.FREE;
|
||||
$: hasUsageFees = hasProjectLimitation
|
||||
? checkForUsageFees($organization?.billingPlan, serviceId)
|
||||
: false;
|
||||
$: isLimited = limit !== 0 && limit < Infinity;
|
||||
$: overflowingServices = limitedServices.filter((service) => service.value >= 0);
|
||||
$: overflowingServices = limitedServices.filter((service) => service.value > 0);
|
||||
$: isButtonDisabled =
|
||||
buttonDisabled ||
|
||||
($readOnly && !GRACE_PERIOD_OVERRIDE) ||
|
||||
@@ -83,12 +85,14 @@
|
||||
|
||||
<!-- Show only if on Cloud, alerts are enabled, and it isn't a project limited service -->
|
||||
{#if isCloud && showAlert}
|
||||
{#if $readOnly}
|
||||
{@const services = overflowingServices
|
||||
.map((s) => {
|
||||
return s.name.toLocaleLowerCase();
|
||||
})
|
||||
.join(', ')}
|
||||
<!-- some services are above limit -->
|
||||
{@const services = overflowingServices
|
||||
.map((s) => {
|
||||
return s.name.toLocaleLowerCase();
|
||||
})
|
||||
.join(', ')}
|
||||
|
||||
{#if services.length}
|
||||
<slot name="alert" {limit} {tier} {title} {upgradeMethod} {hasUsageFees} {services}>
|
||||
{#if $organization?.billingPlan !== BillingPlan.FREE && hasUsageFees}
|
||||
<Alert type="info" isStandalone>
|
||||
@@ -131,6 +135,7 @@
|
||||
<svelte:fragment slot="list">
|
||||
<slot name="tooltip" {limit} {tier} {title} {upgradeMethod} {hasUsageFees}>
|
||||
{#if hasProjectLimitation}
|
||||
{@const count = limit > 1 ? serviceId : serviceId.replace('s', '')}
|
||||
<p class="text">
|
||||
You are limited to {limit}
|
||||
{title.toLocaleLowerCase()} per project on the {tier} plan.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Campaign } from '$lib/stores/campaigns';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import { Typography, Layout, Avatar } from '@appwrite.io/pink-svelte';
|
||||
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
|
||||
|
||||
export const imgLight = LoginLight;
|
||||
export const imgDark = LoginDark;
|
||||
@@ -40,12 +41,6 @@
|
||||
return campaign.description;
|
||||
}
|
||||
}
|
||||
|
||||
function getImage(image: string) {
|
||||
const endpoint = getApiEndpoint();
|
||||
const url = new URL(image, endpoint);
|
||||
return url.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="grid-1-1 is-full-page" id="main">
|
||||
@@ -88,7 +83,7 @@
|
||||
<div style:max-inline-size="30rem" style:height="100%">
|
||||
<Layout.Stack justifyContent="center" alignItems="center" height="100%" gap="xxxl">
|
||||
<img
|
||||
src={getImage(campaign?.image[$app.themeInUse])}
|
||||
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
|
||||
class="u-block u-image-object-fit-cover side-bg-img"
|
||||
alt="promo" />
|
||||
|
||||
@@ -114,7 +109,7 @@
|
||||
<Layout.Stack gap="s" direction="row">
|
||||
{#if currentReview?.image}
|
||||
<Avatar
|
||||
src={getImage(currentReview?.image)}
|
||||
src={getCampaignImageUrl(currentReview?.image)}
|
||||
alt={currentReview.name}
|
||||
size="m" />
|
||||
{:else}
|
||||
@@ -147,7 +142,7 @@
|
||||
</p>
|
||||
<img
|
||||
style:max-block-size="2.5rem"
|
||||
src={getImage(campaign?.image[$app.themeInUse])}
|
||||
src={getCampaignImageUrl(campaign?.image[$app.themeInUse])}
|
||||
alt={coupon?.campaign ?? campaign.$id} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -41,10 +41,20 @@
|
||||
|
||||
export function accumulateFromEndingTotal(
|
||||
metrics: Models.Metric[],
|
||||
endingTotal: number
|
||||
endingTotal: number,
|
||||
startingDayToFillZero: Date = null
|
||||
): Array<[string, number]> {
|
||||
return (metrics ?? []).reduceRight(
|
||||
(acc, curr) => {
|
||||
if (startingDayToFillZero !== null && startingDayToFillZero instanceof Date) {
|
||||
const date = new Date(curr.date);
|
||||
if (date > startingDayToFillZero) {
|
||||
acc.data.unshift([date.toISOString(), 0]);
|
||||
acc.total -= 0;
|
||||
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
acc.data.unshift([curr.date, acc.total]);
|
||||
acc.total -= curr.value;
|
||||
return acc;
|
||||
@@ -77,6 +87,7 @@
|
||||
export let countMetadata: MetricMetadata;
|
||||
export let path: string = null;
|
||||
export let hidePeriodSelect = false;
|
||||
export let isCumulative: boolean = false;
|
||||
</script>
|
||||
|
||||
<Layout.Stack gap="s">
|
||||
@@ -116,7 +127,9 @@
|
||||
series={[
|
||||
{
|
||||
name: countMetadata.legend,
|
||||
data: accumulateFromEndingTotal(count, total)
|
||||
data: isCumulative
|
||||
? count.map((m) => [m.date, m.value])
|
||||
: accumulateFromEndingTotal(count, total)
|
||||
}
|
||||
]} />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
export let count: Models.Metric[][];
|
||||
export let legendData: LegendData[];
|
||||
export let showHeader: boolean = true;
|
||||
export let legendNumberFormat: 'comma' | 'abbreviate' = 'comma';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -55,7 +56,10 @@
|
||||
}))} />
|
||||
|
||||
{#if legendData}
|
||||
<Legend {legendData} />
|
||||
<Legend
|
||||
{legendData}
|
||||
decimalsForAbbreviate={2}
|
||||
numberFormat={legendNumberFormat} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script>
|
||||
export let hideSidebar = false;
|
||||
</script>
|
||||
|
||||
<div class="wizard-secondary-content">
|
||||
<div class="wizard-secondary-content-1">
|
||||
<slot />
|
||||
</div>
|
||||
{#if $$slots.aside}
|
||||
{#if !hideSidebar}
|
||||
<div class="wizard-secondary-content-sep"></div>
|
||||
<div class="wizard-secondary-content-2">
|
||||
<slot name="aside" />
|
||||
|
||||
+198
-27
@@ -1,8 +1,8 @@
|
||||
import type { Client, Models } from '@appwrite.io/console';
|
||||
import type { Organization, OrganizationList } from '../stores/organization';
|
||||
import type { PaymentMethod } from '@stripe/stripe-js';
|
||||
import type { Tier } from '$lib/stores/billing';
|
||||
import type { Campaign } from '$lib/stores/campaigns';
|
||||
import type { Client, Models } from '@appwrite.io/console';
|
||||
import type { PaymentMethod } from '@stripe/stripe-js';
|
||||
import type { Organization, OrganizationError, OrganizationList } from '../stores/organization';
|
||||
|
||||
export type PaymentMethodData = {
|
||||
$id: string;
|
||||
@@ -65,6 +65,32 @@ export type InvoiceList = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Estimation = {
|
||||
amount: number;
|
||||
grossAmount: number;
|
||||
credits: number;
|
||||
discount: number;
|
||||
items: EstimationItem[];
|
||||
discounts: EstimationItem[];
|
||||
trialDays: number;
|
||||
trialEndDate: string | undefined;
|
||||
error: string | undefined;
|
||||
};
|
||||
|
||||
export type EstimationItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type EstimationDeleteOrganization = {
|
||||
amount: number;
|
||||
grossAmount: number;
|
||||
credits: number;
|
||||
discount: number;
|
||||
items: EstimationItem[];
|
||||
unpaidInvoices: Invoice[];
|
||||
};
|
||||
|
||||
export type Coupon = {
|
||||
$id: string;
|
||||
code: string;
|
||||
@@ -147,10 +173,12 @@ export type Aggregation = {
|
||||
* Total amount of the invoice.
|
||||
*/
|
||||
amount: number;
|
||||
additionalMembers: number;
|
||||
|
||||
/**
|
||||
* Price for additional members
|
||||
*/
|
||||
additionalMembers: number;
|
||||
additionalMemberAmount: number;
|
||||
/**
|
||||
* Total storage usage.
|
||||
*/
|
||||
@@ -175,6 +203,10 @@ export type Aggregation = {
|
||||
* Usage logs for the billing period.
|
||||
*/
|
||||
resources: OrganizationUsage;
|
||||
/**
|
||||
* Aggregation billing plan
|
||||
*/
|
||||
plan: string;
|
||||
};
|
||||
|
||||
export type OrganizationUsage = {
|
||||
@@ -182,11 +214,13 @@ export type OrganizationUsage = {
|
||||
executions: Array<Models.Metric>;
|
||||
databasesReads: Array<Models.Metric>;
|
||||
databasesWrites: Array<Models.Metric>;
|
||||
imageTransformations: Array<Models.Metric>;
|
||||
executionsTotal: number;
|
||||
filesStorageTotal: number;
|
||||
buildsStorageTotal: number;
|
||||
databasesReadsTotal: number;
|
||||
databasesWritesTotal: number;
|
||||
imageTransformationsTotal: number;
|
||||
deploymentsStorageTotal: number;
|
||||
executionsMBSecondsTotal: number;
|
||||
buildsMBSecondsTotal: number;
|
||||
@@ -204,6 +238,7 @@ export type OrganizationUsage = {
|
||||
users: number;
|
||||
authPhoneTotal: number;
|
||||
authPhoneEstimate: number;
|
||||
imageTransformations: number;
|
||||
}>;
|
||||
authPhoneTotal: number;
|
||||
authPhoneEstimate: number;
|
||||
@@ -264,14 +299,25 @@ export type AdditionalResource = {
|
||||
multiplier?: number;
|
||||
};
|
||||
|
||||
export type PlanAddon = {
|
||||
supported: boolean;
|
||||
currency: string;
|
||||
invoiceDesc: string;
|
||||
price: number;
|
||||
limit: number;
|
||||
value: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
$id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
price: number;
|
||||
order: number;
|
||||
bandwidth: number;
|
||||
storage: number;
|
||||
members: number;
|
||||
imageTransformations: number;
|
||||
webhooks: number;
|
||||
users: number;
|
||||
teams: number;
|
||||
@@ -283,7 +329,7 @@ export type Plan = {
|
||||
realtime: number;
|
||||
logs: number;
|
||||
authPhone: number;
|
||||
addons: {
|
||||
usage: {
|
||||
bandwidth: AdditionalResource;
|
||||
executions: AdditionalResource;
|
||||
member: AdditionalResource;
|
||||
@@ -291,7 +337,11 @@ export type Plan = {
|
||||
storage: AdditionalResource;
|
||||
users: AdditionalResource;
|
||||
};
|
||||
addons: {
|
||||
seats: PlanAddon;
|
||||
};
|
||||
trialDays: number;
|
||||
budgetCapEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
selfService: boolean;
|
||||
premiumSupport: boolean;
|
||||
@@ -300,9 +350,10 @@ export type Plan = {
|
||||
backupsEnabled: boolean;
|
||||
backupPolicies: number;
|
||||
emailBranding: boolean;
|
||||
supportsCredits: boolean;
|
||||
};
|
||||
|
||||
export type PlansInfo = {
|
||||
export type PlanList = {
|
||||
plans: Plan[];
|
||||
total: number;
|
||||
};
|
||||
@@ -337,20 +388,45 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async validateOrganization(organizationId: string, invites: string[]): Promise<Organization> {
|
||||
const path = `/organizations/${organizationId}/validate`;
|
||||
const params = {
|
||||
organizationId,
|
||||
invites
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
'PATCH',
|
||||
uri,
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async createOrganization(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
billingPlan: string,
|
||||
paymentMethodId: string,
|
||||
billingAddressId: string = undefined
|
||||
): Promise<Organization> {
|
||||
billingAddressId: string = null,
|
||||
couponId: string = null,
|
||||
invites: Array<string> = [],
|
||||
budget: number = undefined,
|
||||
taxId: string = null
|
||||
): Promise<Organization | OrganizationError> {
|
||||
const path = `/organizations`;
|
||||
const params = {
|
||||
organizationId,
|
||||
name,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
billingAddressId
|
||||
billingAddressId,
|
||||
couponId,
|
||||
invites,
|
||||
budget,
|
||||
taxId
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
@@ -363,6 +439,28 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async estimationCreateOrganization(
|
||||
billingPlan: string,
|
||||
couponId: string = null,
|
||||
invites: Array<string> = []
|
||||
): Promise<Estimation> {
|
||||
const path = `/organizations/estimations/create-organization`;
|
||||
const params = {
|
||||
billingPlan,
|
||||
couponId,
|
||||
invites
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
'patch',
|
||||
uri,
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async deleteOrganization(organizationId: string): Promise<Organization> {
|
||||
const path = `/organizations/${organizationId}`;
|
||||
const params = {
|
||||
@@ -379,20 +477,30 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async getPlan(organizationId: string): Promise<Plan> {
|
||||
const path = `/organizations/${organizationId}/plan`;
|
||||
const params = {
|
||||
organizationId
|
||||
};
|
||||
async estimationDeleteOrganization(
|
||||
organizationId: string
|
||||
): Promise<EstimationDeleteOrganization> {
|
||||
const path = `/organizations/${organizationId}/estimations/delete-organization`;
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
'get',
|
||||
uri,
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
params
|
||||
);
|
||||
return await this.client.call('patch', uri, {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationPlan(organizationId: string): Promise<Plan> {
|
||||
const path = `/organizations/${organizationId}/plan`;
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call('get', uri, {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async getPlan(planId: string): Promise<Plan> {
|
||||
const path = `/console/plans/${planId}`;
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call('get', uri, {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async getRoles(organizationId: string): Promise<Roles> {
|
||||
@@ -407,14 +515,22 @@ export class Billing {
|
||||
organizationId: string,
|
||||
billingPlan: string,
|
||||
paymentMethodId: string,
|
||||
billingAddressId: string = undefined
|
||||
): Promise<Organization> {
|
||||
billingAddressId: string = undefined,
|
||||
couponId: string = null,
|
||||
invites: Array<string> = [],
|
||||
budget: number = undefined,
|
||||
taxId: string = null
|
||||
): Promise<Organization | OrganizationError> {
|
||||
const path = `/organizations/${organizationId}/plan`;
|
||||
const params = {
|
||||
organizationId,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
billingAddressId
|
||||
billingAddressId,
|
||||
couponId,
|
||||
invites,
|
||||
budget,
|
||||
taxId
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
@@ -427,6 +543,37 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async estimationUpdatePlan(
|
||||
organizationId: string,
|
||||
billingPlan: string,
|
||||
couponId: string = null,
|
||||
invites: Array<string> = []
|
||||
): Promise<Estimation> {
|
||||
const path = `/organizations/${organizationId}/estimations/update-plan`;
|
||||
const params = {
|
||||
billingPlan,
|
||||
couponId,
|
||||
invites
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
'patch',
|
||||
uri,
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async cancelDowngrade(organizationId: string): Promise<Organization | OrganizationError> {
|
||||
const path = `/organizations/${organizationId}/plan/cancel`;
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call('patch', uri, {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async updateBudget(
|
||||
organizationId: string,
|
||||
budget: number,
|
||||
@@ -594,6 +741,14 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(organizationId: string, invoiceId: string): Promise<Invoice> {
|
||||
const path = `/organizations/${organizationId}/invoices/${invoiceId}/status`;
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call('PATCH', uri, {
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async retryPayment(
|
||||
organizationId: string,
|
||||
invoiceId: string,
|
||||
@@ -750,6 +905,22 @@ export class Billing {
|
||||
}
|
||||
}
|
||||
|
||||
async getCouponAccount(couponId: string): Promise<Coupon> {
|
||||
const path = `/account/coupons/${couponId}`;
|
||||
const params = {
|
||||
couponId
|
||||
};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
return await this.client.call(
|
||||
'GET',
|
||||
uri,
|
||||
{
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async getCoupon(couponId: string): Promise<Coupon> {
|
||||
const path = `/console/coupons/${couponId}`;
|
||||
const params = {
|
||||
@@ -1120,7 +1291,7 @@ export class Billing {
|
||||
);
|
||||
}
|
||||
|
||||
async getPlansInfo(): Promise<PlansInfo> {
|
||||
async getPlansInfo(): Promise<PlanList> {
|
||||
const path = `/console/plans`;
|
||||
const params = {};
|
||||
const uri = new URL(this.client.config.endpoint + path);
|
||||
|
||||
+19
-1
@@ -185,6 +185,14 @@ export type UsageBuckets = {
|
||||
* Aggregated statistics of bucket storage files per period.
|
||||
*/
|
||||
storage: Metric[];
|
||||
/**
|
||||
* Aggregated statistics of bucket image transformations per period.
|
||||
*/
|
||||
imageTransformations: Metric[];
|
||||
/**
|
||||
* Total aggregated number of bucket image transformations.
|
||||
*/
|
||||
imageTransformationsTotal: number;
|
||||
};
|
||||
/**
|
||||
* UsageFunctions
|
||||
@@ -311,7 +319,17 @@ export type UsageProject = {
|
||||
authPhoneEstimate: number;
|
||||
|
||||
/**
|
||||
* Aggregated statistics of total number SMS by country
|
||||
* Aggregated statistics of total number SMS by country.
|
||||
*/
|
||||
authPhoneCountriesBreakdown: Models.MetricBreakdown[];
|
||||
|
||||
/**
|
||||
* Array of image transformations per period.
|
||||
*/
|
||||
imageTransformations: Metric[];
|
||||
|
||||
/**
|
||||
* Aggregated statistics of total number of image transformations.
|
||||
*/
|
||||
imageTransformationsTotal: number;
|
||||
};
|
||||
|
||||
+113
-54
@@ -1,40 +1,41 @@
|
||||
import { page } from '$app/stores';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { sdk } from './sdk';
|
||||
import { organization, type Organization } from './organization';
|
||||
import type {
|
||||
InvoiceList,
|
||||
AddressesList,
|
||||
Invoice,
|
||||
PaymentList,
|
||||
PlansMap,
|
||||
PaymentMethodData,
|
||||
OrganizationUsage,
|
||||
Plan
|
||||
} from '$lib/sdk/billing';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { cachedStore } from '$lib/helpers/cache';
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { headerAlert } from './headerAlert';
|
||||
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
|
||||
import { addNotification, notifications } from './notifications';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
|
||||
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
|
||||
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
|
||||
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { page } from '$app/stores';
|
||||
import LimitReached from '$lib/components/billing/alerts/limitReached.svelte';
|
||||
import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.svelte';
|
||||
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
|
||||
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
|
||||
import { last } from '$lib/helpers/array';
|
||||
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
|
||||
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
|
||||
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
|
||||
import { cachedStore } from '$lib/helpers/cache';
|
||||
import { sizeToBytes, type Size } from '$lib/helpers/sizeConvertion';
|
||||
import { user } from './user';
|
||||
import { browser } from '$app/environment';
|
||||
import type {
|
||||
AddressesList,
|
||||
Aggregation,
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
PaymentList,
|
||||
PaymentMethodData,
|
||||
Plan,
|
||||
PlansMap
|
||||
} from '$lib/sdk/billing';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store';
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { headerAlert } from './headerAlert';
|
||||
import { addNotification, notifications } from './notifications';
|
||||
import { organization, type Organization, type OrganizationError } from './organization';
|
||||
import { canSeeBilling } from './roles';
|
||||
import { sdk } from './sdk';
|
||||
import { user } from './user';
|
||||
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
|
||||
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
|
||||
|
||||
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1';
|
||||
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
@@ -59,12 +60,20 @@ export const roles = [
|
||||
}
|
||||
];
|
||||
|
||||
export const teamStatusReadonly = 'readonly';
|
||||
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
|
||||
|
||||
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
|
||||
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
|
||||
export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
|
||||
export const daysLeftInTrial = writable<number>(0);
|
||||
export const readOnly = writable<boolean>(false);
|
||||
|
||||
export const showBudgetAlert = derived(
|
||||
page,
|
||||
($page) => ($page.data.organization?.billingLimits.budgetLimit ?? 0) >= 100
|
||||
);
|
||||
|
||||
export function getRoleLabel(role: string) {
|
||||
return roles.find((r) => r.value === role)?.label ?? role;
|
||||
}
|
||||
@@ -81,6 +90,8 @@ export function tierToPlan(tier: Tier) {
|
||||
return tierGitHubEducation;
|
||||
case BillingPlan.CUSTOM:
|
||||
return tierCustom;
|
||||
case BillingPlan.ENTERPRISE:
|
||||
return tierEnterprise;
|
||||
default:
|
||||
return tierFree;
|
||||
}
|
||||
@@ -130,7 +141,8 @@ export type PlanServices =
|
||||
| 'usersAddon'
|
||||
| 'webhooks'
|
||||
| 'sites'
|
||||
| 'authPhone';
|
||||
| 'authPhone'
|
||||
| 'imageTransformations';
|
||||
|
||||
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
|
||||
if (!isCloud) return 0;
|
||||
@@ -138,6 +150,14 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
|
||||
const info = get(plansInfo);
|
||||
if (!info) return 0;
|
||||
plan ??= info.get(tier ?? get(organization)?.billingPlan);
|
||||
// members are no longer a variable on plan itself!
|
||||
// the correct info for members/seats, resides in `addons`.
|
||||
// plan > addons > seats/others
|
||||
if (serviceId === 'members') {
|
||||
// some don't include `limit`, so we fallback!
|
||||
return plan?.['addons']['seats']['limit'] ?? 1;
|
||||
}
|
||||
|
||||
return plan?.[serviceId] ?? 0;
|
||||
}
|
||||
|
||||
@@ -151,11 +171,12 @@ export const failedInvoice = cachedStore<
|
||||
load: async (orgId) => {
|
||||
if (!isCloud) set(null);
|
||||
if (!get(canSeeBilling)) set(null);
|
||||
const invoices = await sdk.forConsole.billing.listInvoices(orgId);
|
||||
const failedInvoices = invoices.invoices.filter((i) => i.status === 'failed');
|
||||
const failedInvoices = await sdk.forConsole.billing.listInvoices(orgId, [
|
||||
Query.equal('status', 'failed')
|
||||
]);
|
||||
// const failedInvoices = invoices.invoices;
|
||||
if (failedInvoices?.length > 0) {
|
||||
const firstFailed = failedInvoices[0];
|
||||
if (failedInvoices?.invoices?.length > 0) {
|
||||
const firstFailed = failedInvoices.invoices[0];
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.setDate(today.getDate() - 30));
|
||||
const failedDate = new Date(firstFailed.$createdAt);
|
||||
@@ -200,6 +221,11 @@ export const tierCustom: TierData = {
|
||||
description: 'Team on a custom contract'
|
||||
};
|
||||
|
||||
export const tierEnterprise: TierData = {
|
||||
name: 'Enterprise',
|
||||
description: 'For enterprises that need more power and premium support.'
|
||||
};
|
||||
|
||||
export const showUsageRatesModal = writable<boolean>(false);
|
||||
|
||||
export function checkForUsageFees(plan: Tier, id: PlanServices) {
|
||||
@@ -257,13 +283,35 @@ export function calculateTrialDay(org: Organization) {
|
||||
}
|
||||
|
||||
export async function checkForUsageLimit(org: Organization) {
|
||||
if (org?.billingPlan !== BillingPlan.FREE) {
|
||||
if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
|
||||
headerAlert.add({
|
||||
id: 'teamReadOnlyFailedInvoices',
|
||||
component: TeamReadonlyAlert,
|
||||
show: true,
|
||||
importance: 11
|
||||
});
|
||||
readOnly.set(true);
|
||||
return;
|
||||
}
|
||||
if (!org?.billingLimits && org?.status !== teamStatusReadonly) {
|
||||
readOnly.set(false);
|
||||
return;
|
||||
}
|
||||
if (!org?.billingLimits) {
|
||||
readOnly.set(false);
|
||||
return;
|
||||
if (org?.billingPlan !== BillingPlan.FREE) {
|
||||
const { budgetLimit } = org?.billingLimits ?? {};
|
||||
|
||||
if (budgetLimit && budgetLimit >= 100) {
|
||||
readOnly.set(false);
|
||||
headerAlert.add({
|
||||
id: 'budgetLimit',
|
||||
component: BudgetLimitAlert,
|
||||
show: true,
|
||||
importance: 10
|
||||
});
|
||||
|
||||
readOnly.set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
|
||||
const resources = [
|
||||
@@ -276,7 +324,8 @@ export async function checkForUsageLimit(org: Organization) {
|
||||
|
||||
const members = org.total;
|
||||
const plan = get(plansInfo)?.get(org.billingPlan);
|
||||
const membersOverflow = members > plan.members ? members - (plan.members || members) : 0;
|
||||
const membersOverflow =
|
||||
members - 1 > plan.addons.seats.limit ? members - (plan.addons.seats.limit || members) : 0;
|
||||
|
||||
if (resources.some((r) => r.value >= 100) || membersOverflow > 0) {
|
||||
readOnly.set(true);
|
||||
@@ -456,30 +505,36 @@ export async function checkForNewDevUpgradePro(org: Organization) {
|
||||
if (now - accountCreated < 1000 * 60 * 60 * 24 * 7) return;
|
||||
const isDismissed = !!localStorage.getItem('newDevUpgradePro');
|
||||
if (isDismissed) return;
|
||||
if (now - accountCreated < 1000 * 60 * 60 * 24 * 37) {
|
||||
headerAlert.add({
|
||||
id: 'newDevUpgradePro',
|
||||
component: newDevUpgradePro,
|
||||
show: true,
|
||||
importance: 1
|
||||
});
|
||||
// check if coupon already applied
|
||||
try {
|
||||
await sdk.forConsole.billing.getCouponAccount(NEW_DEV_PRO_UPGRADE_COUPON);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
headerAlert.add({
|
||||
id: 'newDevUpgradePro',
|
||||
component: newDevUpgradePro,
|
||||
show: true,
|
||||
importance: 1
|
||||
});
|
||||
}
|
||||
export const upgradeURL = derived(
|
||||
page,
|
||||
($page) => `${base}/organization-${$page.data?.organization?.$id}/change-plan`
|
||||
);
|
||||
export const billingURL = derived(
|
||||
page,
|
||||
($page) => `${base}/organization-${$page.data?.organization?.$id}/billing`
|
||||
);
|
||||
|
||||
export const hideBillingHeaderRoutes = ['/console/create-organization', '/console/account'];
|
||||
|
||||
export function calculateExcess(usage: OrganizationUsage, plan: Plan, members: number) {
|
||||
const totBandwidth = usage?.bandwidth?.length > 0 ? last(usage.bandwidth).value : 0;
|
||||
export function calculateExcess(addon: Aggregation, plan: Plan) {
|
||||
return {
|
||||
bandwidth: calculateResourceSurplus(totBandwidth, plan.bandwidth),
|
||||
storage: calculateResourceSurplus(usage?.storageTotal, plan.storage, 'GB'),
|
||||
users: calculateResourceSurplus(usage?.usersTotal, plan.users),
|
||||
executions: calculateResourceSurplus(usage?.executionsTotal, plan.executions, 'GB'),
|
||||
members: calculateResourceSurplus(members, plan.members)
|
||||
bandwidth: calculateResourceSurplus(addon.usageBandwidth, plan.bandwidth),
|
||||
storage: calculateResourceSurplus(addon.usageStorage, plan.storage, 'GB'),
|
||||
executions: calculateResourceSurplus(addon.usageExecutions, plan.executions, 'GB'),
|
||||
members: addon.additionalMembers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,3 +543,7 @@ export function calculateResourceSurplus(total: number, limit: number, limitUnit
|
||||
const realLimit = (limitUnit ? sizeToBytes(limit, limitUnit) : limit) || Infinity;
|
||||
return total > realLimit ? total - realLimit : 0;
|
||||
}
|
||||
|
||||
export function isOrganization(org: Organization | OrganizationError): org is Organization {
|
||||
return (org as Organization).$id !== undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { headerAlert } from '$lib/stores/headerAlert';
|
||||
import BackupDatabase from '$lib/components/backupDatabaseAlert.svelte';
|
||||
import { shouldShowNotification } from '$lib/helpers/notifications';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
|
||||
export const database = derived(page, ($page) => $page.data?.database as Models.Database);
|
||||
|
||||
@@ -17,16 +19,19 @@ export async function checkForDatabaseBackupPolicies(database: Models.Database)
|
||||
if (!shouldShowNotification(backupsBannerId)) return;
|
||||
|
||||
let total = 0;
|
||||
const backupsEnabled = get(currentPlan)?.backupsEnabled ?? true;
|
||||
|
||||
try {
|
||||
const policies = await sdk.forProject.backups.listPolicies([
|
||||
Query.limit(1),
|
||||
Query.equal('resourceId', database.$id)
|
||||
]);
|
||||
if (isCloud && backupsEnabled) {
|
||||
try {
|
||||
const policies = await sdk.forProject.backups.listPolicies([
|
||||
Query.limit(1),
|
||||
Query.equal('resourceId', database.$id)
|
||||
]);
|
||||
|
||||
total = policies.total;
|
||||
} catch (e) {
|
||||
// ignore, backups not allowed on free plan error.
|
||||
total = policies.total;
|
||||
} catch (e) {
|
||||
// ignore, backups not allowed on free plan error.
|
||||
}
|
||||
}
|
||||
|
||||
showPolicyAlert.set(total <= 0);
|
||||
|
||||
@@ -115,10 +115,26 @@ function createFeedbackStore() {
|
||||
email?: string,
|
||||
// eslint-disable-next-line
|
||||
billingPlan?: string,
|
||||
value?: number
|
||||
value?: number,
|
||||
orgId?: string,
|
||||
projectId?: string,
|
||||
userId?: string
|
||||
) => {
|
||||
if (!VARS.GROWTH_ENDPOINT) return;
|
||||
trackEvent(Submit.FeedbackSubmit);
|
||||
|
||||
const customFields: Array<{ id: string; value: string | number }> = [
|
||||
{ id: '47364', value: currentPage }
|
||||
];
|
||||
|
||||
if (value) {
|
||||
customFields.push({ id: '40655', value });
|
||||
}
|
||||
|
||||
if (billingPlan) {
|
||||
customFields.push({ id: '56109', value: billingPlan });
|
||||
}
|
||||
|
||||
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -128,14 +144,13 @@ function createFeedbackStore() {
|
||||
subject,
|
||||
message,
|
||||
email,
|
||||
// billingPlan,
|
||||
customFields,
|
||||
firstname: name || 'Unknown',
|
||||
customFields: [
|
||||
{ id: '47364', value: currentPage },
|
||||
...(value ? [{ id: '40655', value }] : [])
|
||||
],
|
||||
metaFields: {
|
||||
source: get(feedback).source
|
||||
source: get(feedback).source,
|
||||
orgId,
|
||||
projectId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -101,6 +101,12 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
docs: 'https://developers.facebook.com/',
|
||||
component: Main
|
||||
},
|
||||
figma: {
|
||||
name: 'Figma',
|
||||
icon: 'figma',
|
||||
docs: 'https://www.figma.com/developers/api#access-tokens',
|
||||
component: Main
|
||||
},
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icon: 'github',
|
||||
|
||||
@@ -4,6 +4,15 @@ import type { Models } from '@appwrite.io/console';
|
||||
import type { Tier } from './billing';
|
||||
import type { Plan } from '$lib/sdk/billing';
|
||||
|
||||
export type OrganizationError = {
|
||||
status: number;
|
||||
message: string;
|
||||
teamId: string;
|
||||
invoiceId: string;
|
||||
clientSecret: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type Organization = Models.Team<Record<string, unknown>> & {
|
||||
billingBudget: number;
|
||||
billingPlan: Tier;
|
||||
@@ -21,6 +30,10 @@ export type Organization = Models.Team<Record<string, unknown>> & {
|
||||
amount: number;
|
||||
billingTaxId?: string;
|
||||
billingPlanDowngrade?: Tier;
|
||||
billingAggregationId: string;
|
||||
billingInvoiceId: string;
|
||||
status: string;
|
||||
remarks: string;
|
||||
};
|
||||
|
||||
export type OrganizationList = {
|
||||
@@ -34,6 +47,7 @@ export type BillingLimits = {
|
||||
executions: number;
|
||||
storage: number;
|
||||
users: number;
|
||||
budgetLimit: number;
|
||||
};
|
||||
|
||||
export const newOrgModal = writable<boolean>(false);
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<svelte:fragment slot="aside">
|
||||
<BoxAvatar>
|
||||
<svelte:fragment slot="image">
|
||||
<AvatarInitials size="l" name={$user.name} />
|
||||
<AvatarInitials size="m" name={$user.name || $user.email} />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="u-bold u-trim-1" data-private>{$user.name}</span>
|
||||
<span class="u-bold u-trim-1" data-private>{$user.name || 'User'}</span>
|
||||
</svelte:fragment>
|
||||
</BoxAvatar>
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -22,10 +22,6 @@
|
||||
|
||||
onMount(async () => {
|
||||
const countryList = await sdk.forProject.locale.listCountries();
|
||||
const locale = await sdk.forProject.locale.get();
|
||||
if (locale.countryCode) {
|
||||
selectedAddress.country = locale.countryCode;
|
||||
}
|
||||
options = countryList.countries.map((country) => {
|
||||
return {
|
||||
value: country.code,
|
||||
@@ -57,6 +53,21 @@
|
||||
trackError(e, Submit.BillingAddressUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This component isn't a modal, so it mounts with its parent.
|
||||
* On mount, `selectedAddress` can be `null`, so we set the country when the modal shows.
|
||||
*
|
||||
* And we only run this if the selected address has no country set,
|
||||
* which really shouldn't happen, but here we are, playing it safe!
|
||||
*/
|
||||
$: if (show && !selectedAddress.country) {
|
||||
sdk.forProject.locale.get().then((locale) => {
|
||||
if (locale.countryCode) {
|
||||
selectedAddress.country = locale.countryCode;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:error bind:show onSubmit={handleSubmit} size="m" title="Update billing address">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let name: string = null;
|
||||
|
||||
onMount(async () => {
|
||||
name ??= $user.name;
|
||||
name ??= $user.name ?? '';
|
||||
});
|
||||
|
||||
async function updateName() {
|
||||
|
||||
@@ -3,25 +3,27 @@
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import {
|
||||
CreditsApplied,
|
||||
EstimatedTotalBox,
|
||||
SelectPaymentMethod
|
||||
} from '$lib/components/billing';
|
||||
import { CreditsApplied, EstimatedTotal, SelectPaymentMethod } from '$lib/components/billing';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Button, Form, InputSelect, InputTags, InputText } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { Wizard } from '$lib/layout';
|
||||
import { type PaymentList } from '$lib/sdk/billing';
|
||||
import { type PaymentList, type Plan } from '$lib/sdk/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organizationList, type Organization } from '$lib/stores/organization';
|
||||
import {
|
||||
organizationList,
|
||||
type Organization,
|
||||
type OrganizationError
|
||||
} from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { confirmPayment } from '$lib/stores/stripe.js';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { isOrganization, plansInfo, type Tier } from '$lib/stores/billing';
|
||||
import { Fieldset, Icon, Layout, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
import EstimatedTotalBox from '$lib/components/billing/estimatedTotalBox.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -66,13 +68,19 @@
|
||||
let coupon: string;
|
||||
let couponData = data?.couponData;
|
||||
let campaign = data?.campaign;
|
||||
let billingPlan = BillingPlan.PRO;
|
||||
let billingPlan: Tier = BillingPlan.PRO;
|
||||
let tempOrgId = null;
|
||||
let currentPlan: Plan;
|
||||
|
||||
$: onlyNewOrgs = (campaign && campaign.onlyNewOrgs) || (couponData && couponData.onlyNewOrgs);
|
||||
|
||||
$: selectedOrgId = tempOrgId;
|
||||
|
||||
function isUpgrade() {
|
||||
const newPlan = page.data.plansInfo.get(billingPlan);
|
||||
return currentPlan && newPlan && currentPlan.order < newPlan.order;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadPaymentMethods();
|
||||
if (!$organizationList?.total || campaign?.onlyNewOrgs) {
|
||||
@@ -89,6 +97,19 @@
|
||||
if ($organizationList.total > 0 && tempOrgId === null) {
|
||||
tempOrgId = $organizationList.teams[0].$id;
|
||||
}
|
||||
if (page.url.searchParams.has('type')) {
|
||||
const type = page.url.searchParams.get('type');
|
||||
if (type === 'payment_confirmed') {
|
||||
const organizationId = page.url.searchParams.get('id');
|
||||
collaborators = page.url.searchParams.get('invites').split(',');
|
||||
|
||||
await sdk.forConsole.billing.validateOrganization(organizationId, collaborators);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedOrgId && $organizationList?.total) {
|
||||
selectedOrgId = $organizationList.teams[0].$id;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPaymentMethods() {
|
||||
@@ -103,76 +124,82 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!couponForm.checkValidity()) return;
|
||||
isSubmitting.set(true);
|
||||
try {
|
||||
let org: Organization;
|
||||
let org: Organization | OrganizationError;
|
||||
// Create new org
|
||||
if (selectedOrgId === newOrgId) {
|
||||
org = await sdk.forConsole.billing.createOrganization(
|
||||
newOrgId,
|
||||
name,
|
||||
billingPlan,
|
||||
paymentMethodId
|
||||
paymentMethodId,
|
||||
null,
|
||||
couponData.code ? couponData.code : null,
|
||||
collaborators,
|
||||
billingBudget,
|
||||
taxId
|
||||
);
|
||||
}
|
||||
|
||||
// Upgrade existing org
|
||||
else if (selectedOrg?.billingPlan !== billingPlan) {
|
||||
else if (selectedOrg?.billingPlan !== billingPlan && isUpgrade()) {
|
||||
org = await sdk.forConsole.billing.updatePlan(
|
||||
selectedOrg.$id,
|
||||
billingPlan,
|
||||
paymentMethodId,
|
||||
null
|
||||
null,
|
||||
couponData.code ? couponData.code : null,
|
||||
collaborators
|
||||
);
|
||||
}
|
||||
// Existing pro org
|
||||
// Existing pro org, apply credits
|
||||
else {
|
||||
org = selectedOrg;
|
||||
}
|
||||
|
||||
// Add coupon
|
||||
if (couponData?.code) {
|
||||
await sdk.forConsole.billing.addCredit(org.$id, couponData.code);
|
||||
}
|
||||
|
||||
// Add budget
|
||||
if (billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
|
||||
if (!isOrganization(org) && org.status === 402) {
|
||||
let clientSecret = org.clientSecret;
|
||||
let params = new URLSearchParams();
|
||||
params.append('type', 'payment_confirmed');
|
||||
params.append('org', org.teamId);
|
||||
for (const [key, value] of page.url.searchParams.entries()) {
|
||||
if (key !== 'type' && key !== 'id') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
params.append('invites', collaborators.join(','));
|
||||
await confirmPayment(
|
||||
'',
|
||||
clientSecret,
|
||||
paymentMethodId,
|
||||
'/console/apply-credit?' + params.toString()
|
||||
);
|
||||
org = await sdk.forConsole.billing.validateOrganization(org.teamId, collaborators);
|
||||
}
|
||||
|
||||
// Add collaborators
|
||||
if (collaborators?.length) {
|
||||
collaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${page.url.origin}${base}/invite`
|
||||
);
|
||||
if (isOrganization(org)) {
|
||||
trackEvent(Submit.CreditRedeem, {
|
||||
coupon: couponData.code,
|
||||
campaign: couponData?.campaign
|
||||
});
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
await goto(`${base}/organization-${org.$id}`);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
}
|
||||
|
||||
// Add tax ID
|
||||
if (taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
|
||||
}
|
||||
trackEvent(Submit.CreditRedeem, {
|
||||
coupon: couponData.code,
|
||||
campaign: couponData?.campaign
|
||||
});
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
await goto(`${base}/organization-${org.$id}`);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
} catch (e) {
|
||||
trackError(e, Submit.CreditRedeem);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
} finally {
|
||||
isSubmitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +224,32 @@
|
||||
(team) => team.$id === selectedOrgId
|
||||
) as Organization;
|
||||
|
||||
$: billingPlan =
|
||||
selectedOrg?.billingPlan === BillingPlan.SCALE
|
||||
? BillingPlan.SCALE
|
||||
: (campaign?.plan ?? BillingPlan.PRO);
|
||||
function getBillingPlan(): Tier | undefined {
|
||||
const campaignPlan =
|
||||
campaign?.plan && $plansInfo.get(campaign.plan) ? $plansInfo.get(campaign.plan) : null;
|
||||
const orgPlan =
|
||||
selectedOrg?.billingPlan && $plansInfo.get(selectedOrg.billingPlan)
|
||||
? $plansInfo.get(selectedOrg.billingPlan)
|
||||
: null;
|
||||
|
||||
if (!campaignPlan || !orgPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
return campaignPlan.order > orgPlan.order ? campaign.plan : selectedOrg?.billingPlan;
|
||||
}
|
||||
|
||||
$: if (selectedOrg) {
|
||||
billingPlan = getBillingPlan();
|
||||
}
|
||||
|
||||
$: {
|
||||
if (selectedOrgId) {
|
||||
(async () => {
|
||||
currentPlan = await sdk.forConsole.billing.getOrganizationPlan(selectedOrgId);
|
||||
})();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -286,7 +335,7 @@
|
||||
</Form>
|
||||
</Layout.Stack>
|
||||
<svelte:fragment slot="aside">
|
||||
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE && selectedOrg?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
|
||||
{#if selectedOrg?.$id && selectedOrg?.billingPlan === billingPlan}
|
||||
<section
|
||||
class="card"
|
||||
style:--p-card-padding="1.5rem"
|
||||
|
||||
@@ -3,16 +3,15 @@ import type { Coupon } from '$lib/sdk/billing.js';
|
||||
import type { Campaign } from '$lib/stores/campaigns.js';
|
||||
import { sdk } from '$lib/stores/sdk.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
export const load = async ({ url }) => {
|
||||
// Has promo code
|
||||
if (url.searchParams.has('code')) {
|
||||
let couponData: Coupon;
|
||||
let campaign: Campaign;
|
||||
const code = url.searchParams.get('code');
|
||||
try {
|
||||
couponData = await sdk.forConsole.billing.getCoupon(code);
|
||||
couponData = await sdk.forConsole.billing.getCouponAccount(code);
|
||||
if (couponData.campaign) {
|
||||
campaign = await sdk.forConsole.billing.getCampaign(couponData.campaign);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,28 @@
|
||||
import { page } from '$app/state';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import {
|
||||
EstimatedTotalBox,
|
||||
PlanComparisonBox,
|
||||
PlanSelection,
|
||||
SelectPaymentMethod
|
||||
SelectPaymentMethod,
|
||||
SelectPlan
|
||||
} from '$lib/components/billing';
|
||||
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Button, Form, InputTags, InputText } from '$lib/elements/forms';
|
||||
import { Wizard } from '$lib/layout';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { tierToPlan } from '$lib/stores/billing';
|
||||
|
||||
import type { Coupon, PaymentList } from '$lib/sdk/billing';
|
||||
import { isOrganization, tierToPlan } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import type { Organization } from '$lib/stores/organization';
|
||||
import { type OrganizationError, type Organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { confirmPayment } from '$lib/stores/stripe';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { 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}
|
||||
|
||||
|
||||
+6
-4
@@ -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 } }
|
||||
]);
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
// verify invoice exists
|
||||
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
|
||||
const endpoint = getApiEndpoint();
|
||||
|
||||
return redirect(
|
||||
302,
|
||||
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/download`
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
// verify invoice exists
|
||||
const invoice = await sdk.forConsole.billing.getInvoice(params.organization, params.invoiceId);
|
||||
const endpoint = getApiEndpoint();
|
||||
|
||||
return redirect(
|
||||
302,
|
||||
`${endpoint}/organizations/${params.organization}/invoices/${invoice.$id}/view`
|
||||
);
|
||||
};
|
||||
@@ -1,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(', ')}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components';
|
||||
import type { EstimationDeleteOrganization } from '$lib/sdk/billing';
|
||||
import InvoicesTable from './invoicesTable.svelte';
|
||||
export let estimation: EstimationDeleteOrganization;
|
||||
</script>
|
||||
|
||||
{#if estimation}
|
||||
{#if estimation.unpaidInvoices?.length > 0}
|
||||
<Alert type="warning">
|
||||
This organization has unresolved invoices that must be settled before it can be deleted.
|
||||
Please review and resolve these invoices to proceed.
|
||||
</Alert>
|
||||
|
||||
<InvoicesTable invoices={estimation.unpaidInvoices} showActions={false} />
|
||||
{/if}
|
||||
{/if}
|
||||
+71
-51
@@ -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 organization’s 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
|
||||
};
|
||||
};
|
||||
|
||||
+5
-4
@@ -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':
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user