mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
Merge pull request #9635 from supabase/master
Prod deploy for Dashboard
This commit is contained in:
32 files changed
+678
-420
No files matched your search
@@ -9,7 +9,6 @@ on:
|
||||
- 'studio/**'
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -18,15 +17,15 @@ jobs:
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- name: Install deps
|
||||
run: npm i
|
||||
working-directory: ./studio
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./studio
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
working-directory: ./
|
||||
- name: Run build
|
||||
run: npx turbo run build --filter=studio
|
||||
working-directory: ./
|
||||
@@ -14,7 +14,7 @@ const config = {
|
||||
url: 'https://supabase.com',
|
||||
baseUrl: baseUrl,
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
onBrokenMarkdownLinks: 'throw',
|
||||
favicon: '/favicon.ico',
|
||||
themes: ['docusaurus-theme-search-typesense'],
|
||||
|
||||
|
||||
+88
-74
@@ -270,7 +270,7 @@ module.exports = [
|
||||
{
|
||||
permanent: false,
|
||||
source: '/docs/client/auth-session',
|
||||
destination: '/docs/reference/javascript/auth-session',
|
||||
destination: '/docs/reference/javascript/v1/auth-session',
|
||||
},
|
||||
{
|
||||
permanent: false,
|
||||
@@ -325,12 +325,12 @@ module.exports = [
|
||||
{
|
||||
permanent: false,
|
||||
source: '/docs/client/removesubscription',
|
||||
destination: '/docs/reference/javascript/removesubscription',
|
||||
destination: '/docs/reference/javascript/v1/removesubscription',
|
||||
},
|
||||
{
|
||||
permanent: false,
|
||||
source: '/docs/client/getsubscriptions',
|
||||
destination: '/docs/reference/javascript/getsubscriptions',
|
||||
destination: '/docs/reference/javascript/v1/getsubscriptions',
|
||||
},
|
||||
{
|
||||
permanent: false,
|
||||
@@ -1359,65 +1359,84 @@ module.exports = [
|
||||
destination: '/docs/guides/auth/auth-helpers/sveltekit',
|
||||
},
|
||||
|
||||
// V2 redirects
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-update',
|
||||
// destination: '/docs/reference/javascript/auth-updateuser',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-getuser',
|
||||
// destination: '/docs/reference/javascript/auth-getuser',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-resetpasswordforemail',
|
||||
// destination: '/docs/reference/javascript/auth-resetpasswordforemail',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-verifyotp',
|
||||
// destination: '/docs/reference/javascript/auth-verifyotp',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-listusers',
|
||||
// destination: '/docs/reference/javascript/auth-admin-listusers',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-createuser',
|
||||
// destination: '/docs/reference/javascript/auth-admin-createuser',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-deleteuser',
|
||||
// destination: '/docs/reference/javascript/auth-admin-deleteuser',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-generatelink',
|
||||
// destination: '/docs/reference/javascript/auth-admin-generatelink',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-inviteuserbyemail',
|
||||
// destination: '/docs/reference/javascript/auth-admin-inviteuserbyemail',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-getuserbyid',
|
||||
// destination: '/docs/reference/javascript/auth-admin-getuserbyid',
|
||||
// },
|
||||
// {
|
||||
// permanent: true,
|
||||
// source: '/docs/reference/javascript/auth-api-updateuserbyid',
|
||||
// destination: '/docs/reference/javascript/auth-admin-updateuserbyid',
|
||||
// },
|
||||
|
||||
// v1 to v2 redirects
|
||||
|
||||
// supabase-js v1 to v2 redirects
|
||||
// v1: /auth-update
|
||||
// v2: /auth-updateuser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-update',
|
||||
destination: '/docs/reference/javascript/v1/auth-update',
|
||||
},
|
||||
// v1: /auth-api-get-user
|
||||
// v2: /auth-getuser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-getuser',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-getuser',
|
||||
},
|
||||
// v1: /auth-api-resetpasswordforemail
|
||||
// v2: /auth-resetpasswordforemail
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-resetpasswordforemail',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-resetpasswordforemail',
|
||||
},
|
||||
// v1: /auth-api-verifyotp
|
||||
// v2: /auth-verifyotp
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-verifyotp',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-verifyotp',
|
||||
},
|
||||
// v1: /auth-api-listusers
|
||||
// v2: /auth-admin-listusers
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-listusers',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-listusers',
|
||||
},
|
||||
// v1: /auth-api-createuser
|
||||
// v2: /auth-admin-createuser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-createuser',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-createuser',
|
||||
},
|
||||
// v1: /auth-api-deleteuser
|
||||
// v2: /auth-admin-deleteuser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-deleteuser',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-deleteuser',
|
||||
},
|
||||
// v1: /auth-api-generatelink
|
||||
// v2: /auth-admin-generatelink
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-generatelink',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-generatelink',
|
||||
},
|
||||
// v1: /auth-api-inviteuserbyemail
|
||||
// v2: /auth-admin-inviteuserbyemail
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-inviteuserbyemail',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-inviteuserbyemail',
|
||||
},
|
||||
// v1: /auth-api-getuserbyid
|
||||
// v2: /auth-admin-getuserbyid
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-getuserbyid',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-getuserbyid',
|
||||
},
|
||||
// v1: /auth-api-updateuserbyid
|
||||
// v2: /auth-admin-updateuserbyid
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-updateuserbyid',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-updateuserbyid',
|
||||
},
|
||||
// signIn method is now split into signInWithPassword ,signInWithPasswordless ,signInWithOAuth
|
||||
// send traffic to v1 docs instead
|
||||
{
|
||||
@@ -1425,26 +1444,21 @@ module.exports = [
|
||||
source: '/docs/reference/javascript/auth-signin',
|
||||
destination: '/docs/reference/javascript/v1/auth-signin',
|
||||
},
|
||||
// auth.session renamed to auth.getSession
|
||||
// send to new v2 docs version
|
||||
// v1: /auth-session
|
||||
// v2: /auth-getsession
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-session',
|
||||
destination: '/docs/reference/javascript/auth-getsession',
|
||||
destination: '/docs/reference/javascript/v1/auth-session',
|
||||
},
|
||||
// Removed sendMobileOTP in favor of signInWithOtp
|
||||
// send traffic to new v2 docs instead
|
||||
// v1: /auth-api-sendmobileotp
|
||||
// v2: /auth-signinwithotp
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-api-sendmobileotp',
|
||||
destination: '/docs/reference/javascript/auth-signinwithotp',
|
||||
},
|
||||
// Replaced update with updateUser
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/auth-update',
|
||||
destination: '/docs/reference/javascript/auth-updateuser',
|
||||
destination: '/docs/reference/javascript/v1/auth-api-sendmobileotp',
|
||||
},
|
||||
|
||||
// realtime methods been replaced with new names
|
||||
{
|
||||
permanent: true,
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Section = ({ children, icon, img }) => (
|
||||
|
||||
### SOC 2
|
||||
|
||||
Supabase is SOC2 Type 1 compliant. Enterprise customers can request a copy of our SOC2 through our [Security Portal](https://security.supabase.com).
|
||||
Supabase is SOC2 Type 1 compliant. Enterprise customers can request a copy of our SOC2 [here](https://supabase.com/legal/soc2).
|
||||
|
||||
<div className="w-16">
|
||||
<img src="/images/security/21972-312_SOC_NonCPA.png" />
|
||||
@@ -126,8 +126,7 @@ In addition to internal security reviews, we use various tools to scan our code
|
||||
|
||||
<h3 className="text-3xl">Security Portal</h3>
|
||||
|
||||
Access Supabase’s security and compliance documents on our Security Portal, such as
|
||||
penetration testing and audit reports.
|
||||
Access more of Supabase’s security on our Security Portal, such as penetration testing and audit reports.
|
||||
|
||||
<Link passHref href="https://security.supabase.com">
|
||||
<Button type="default" size="medium" as="a" className="no-underline" target="_blank">
|
||||
|
||||
Generated
+13
@@ -16,6 +16,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^2.1.1",
|
||||
"html-to-image": "^1.10.1",
|
||||
"mini-svg-data-uri": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -18169,6 +18170,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.10.8",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.10.8.tgz",
|
||||
"integrity": "sha512-t+JyFJwKDCp4ZwBp4iC/wqw0meQDDc77Qs8OFl5P7RGlIP3LQMvwpD7VXxqQfC7/TfC+GKYlFP6WDYfXTmXHfw=="
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
@@ -36098,6 +36104,7 @@
|
||||
"dayjs": "^1.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"generate-password": "^1.7.0",
|
||||
"html-to-image": "^1.10.8",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"json-logic-js": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -50190,6 +50197,11 @@
|
||||
"integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==",
|
||||
"dev": true
|
||||
},
|
||||
"html-to-image": {
|
||||
"version": "1.10.8",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.10.8.tgz",
|
||||
"integrity": "sha512-t+JyFJwKDCp4ZwBp4iC/wqw0meQDDc77Qs8OFl5P7RGlIP3LQMvwpD7VXxqQfC7/TfC+GKYlFP6WDYfXTmXHfw=="
|
||||
},
|
||||
"html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
@@ -60621,6 +60633,7 @@
|
||||
"dayjs": "^1.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"generate-password": "^1.7.0",
|
||||
"html-to-image": "^1.10.8",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"jest": "^27.4.0",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
|
||||
+172
-8
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 1.0.0
|
||||
version: 1.8.5
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -11,8 +11,9 @@ info:
|
||||
Supabase CLI provides you with tools to develop your application locally, and deploy your application to the Supabase platform.
|
||||
options: |-
|
||||
```sh
|
||||
--debug output debug logs to stderr
|
||||
-h, --help help for supabase
|
||||
--debug output debug logs to stderr
|
||||
-h, --help help for supabase
|
||||
--workdir string path to a Supabase project directory
|
||||
```
|
||||
commands:
|
||||
- id: supabase-stop
|
||||
@@ -27,7 +28,8 @@ commands:
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for stop
|
||||
--backup Backs up the current database before stopping.
|
||||
-h, --help help for stop
|
||||
```
|
||||
- id: supabase-status
|
||||
title: supabase status
|
||||
@@ -299,7 +301,7 @@ commands:
|
||||
title: supabase gen types typescript
|
||||
summary: Generate types for TypeScript
|
||||
description: |
|
||||
Generate types for TypeScript. Must specify either --local or --db-url
|
||||
Generate types for TypeScript. Must specify one of --local, --linked, --project-id, or --db-url
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
@@ -309,9 +311,12 @@ commands:
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
--db-url string Generate types from a database url.
|
||||
-h, --help help for typescript
|
||||
--local Generate types from the local dev database.
|
||||
--db-url string Generate types from a database url.
|
||||
-h, --help help for typescript
|
||||
--linked Generate types from the linked project.
|
||||
--local Generate types from the local dev database.
|
||||
--project-id string Generate types from a project ID.
|
||||
--schema stringArray Schemas to generate types for.
|
||||
```
|
||||
- id: supabase-functions
|
||||
title: supabase functions
|
||||
@@ -399,6 +404,7 @@ commands:
|
||||
subcommands:
|
||||
- supabase-db-branch
|
||||
- supabase-db-diff
|
||||
- supabase-db-lint
|
||||
- supabase-db-push
|
||||
- supabase-db-remote
|
||||
- supabase-db-reset
|
||||
@@ -478,6 +484,22 @@ commands:
|
||||
-h, --help help for push
|
||||
-p, --password string Password to your remote Postgres database.
|
||||
```
|
||||
- id: supabase-db-lint
|
||||
title: supabase db lint
|
||||
summary: Checks local database for typing error
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
```sh
|
||||
supabase db lint [flags]
|
||||
```
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for lint
|
||||
--level string Error level to emit. (default "warning")
|
||||
-s, --schema strings List of schema to include. (default [public])
|
||||
```
|
||||
- id: supabase-db-diff
|
||||
title: supabase db diff
|
||||
summary: Diffs the local database for schema changes
|
||||
@@ -567,3 +589,145 @@ commands:
|
||||
```sh
|
||||
-h, --help help for create
|
||||
```
|
||||
- id: supabase-completion
|
||||
title: supabase completion
|
||||
summary: Generate the autocompletion script for the specified shell
|
||||
description: |
|
||||
Generate the autocompletion script for supabase for the specified shell.
|
||||
See each sub-command's help for details on how to use the generated script.
|
||||
tags: []
|
||||
links: []
|
||||
subcommands:
|
||||
- supabase-completion-bash
|
||||
- supabase-completion-fish
|
||||
- supabase-completion-powershell
|
||||
- supabase-completion-zsh
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for completion
|
||||
```
|
||||
- id: supabase-completion-zsh
|
||||
title: supabase completion zsh
|
||||
summary: Generate the autocompletion script for zsh
|
||||
description: |
|
||||
Generate the autocompletion script for the zsh shell.
|
||||
|
||||
If shell completion is not already enabled in your environment you will need
|
||||
to enable it. You can execute the following once:
|
||||
|
||||
echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
To load completions in your current shell session:
|
||||
|
||||
source <(supabase completion zsh); compdef _supabase supabase
|
||||
|
||||
To load completions for every new session, execute once:
|
||||
|
||||
#### Linux:
|
||||
|
||||
supabase completion zsh > "${fpath[1]}/_supabase"
|
||||
|
||||
#### macOS:
|
||||
|
||||
supabase completion zsh > $(brew --prefix)/share/zsh/site-functions/_supabase
|
||||
|
||||
You will need to start a new shell for this setup to take effect.
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
```sh
|
||||
supabase completion zsh [flags]
|
||||
```
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for zsh
|
||||
--no-descriptions disable completion descriptions
|
||||
```
|
||||
- id: supabase-completion-powershell
|
||||
title: supabase completion powershell
|
||||
summary: Generate the autocompletion script for powershell
|
||||
description: |
|
||||
Generate the autocompletion script for powershell.
|
||||
|
||||
To load completions in your current shell session:
|
||||
|
||||
supabase completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
To load completions for every new session, add the output of the above command
|
||||
to your powershell profile.
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
```sh
|
||||
supabase completion powershell [flags]
|
||||
```
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for powershell
|
||||
--no-descriptions disable completion descriptions
|
||||
```
|
||||
- id: supabase-completion-fish
|
||||
title: supabase completion fish
|
||||
summary: Generate the autocompletion script for fish
|
||||
description: |
|
||||
Generate the autocompletion script for the fish shell.
|
||||
|
||||
To load completions in your current shell session:
|
||||
|
||||
supabase completion fish | source
|
||||
|
||||
To load completions for every new session, execute once:
|
||||
|
||||
supabase completion fish > ~/.config/fish/completions/supabase.fish
|
||||
|
||||
You will need to start a new shell for this setup to take effect.
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
```sh
|
||||
supabase completion fish [flags]
|
||||
```
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for fish
|
||||
--no-descriptions disable completion descriptions
|
||||
```
|
||||
- id: supabase-completion-bash
|
||||
title: supabase completion bash
|
||||
summary: Generate the autocompletion script for bash
|
||||
description: |
|
||||
Generate the autocompletion script for the bash shell.
|
||||
|
||||
This script depends on the 'bash-completion' package.
|
||||
If it is not installed already, you can install it via your OS's package manager.
|
||||
|
||||
To load completions in your current shell session:
|
||||
|
||||
source <(supabase completion bash)
|
||||
|
||||
To load completions for every new session, execute once:
|
||||
|
||||
#### Linux:
|
||||
|
||||
supabase completion bash > /etc/bash_completion.d/supabase
|
||||
|
||||
#### macOS:
|
||||
|
||||
supabase completion bash > $(brew --prefix)/etc/bash_completion.d/supabase
|
||||
|
||||
You will need to start a new shell for this setup to take effect.
|
||||
tags: []
|
||||
links: []
|
||||
usage: |-
|
||||
```sh
|
||||
supabase completion bash
|
||||
```
|
||||
subcommands: []
|
||||
options: |-
|
||||
```sh
|
||||
-h, --help help for bash
|
||||
--no-descriptions disable completion descriptions
|
||||
```
|
||||
@@ -53,7 +53,7 @@ const Users = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-scroll">
|
||||
<div>
|
||||
<div className="justify-between px-6 pt-6 pb-2 md:flex">
|
||||
<div className="relative flex space-x-4">
|
||||
<Input
|
||||
@@ -108,8 +108,8 @@ const Users = () => {
|
||||
{inviteEnabled && <InviteUserModal />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-4 overflow-visible px-6">
|
||||
<div className="section-block--body relative overflow-scroll rounded">
|
||||
<section className="users-table-container mt-4 overflow-visible px-6">
|
||||
<div className="section-block--body relative overflow-x-scroll rounded">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<UsersList />
|
||||
</div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export const CANCELLATION_REASONS = [
|
||||
'Pricing',
|
||||
"My project isn't getting traction",
|
||||
'Poor customer service',
|
||||
'Missing feature',
|
||||
"I didn't see the value",
|
||||
"Supabase didn't meet my needs",
|
||||
'Dashboard is too complicated',
|
||||
'Postgres is too complicated',
|
||||
'Problem not solved',
|
||||
'Bug issues',
|
||||
'I decided to use something else',
|
||||
'My work has finished/discontinued',
|
||||
'I’m migrating to/starting a new project',
|
||||
'None of the above',
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
import { IconArchive, IconDatabase, IconKey } from 'ui'
|
||||
|
||||
export const CANCELLATION_REASONS = [
|
||||
'Pricing',
|
||||
"My project isn't getting traction",
|
||||
'Poor customer service',
|
||||
'Missing feature',
|
||||
"I didn't see the value",
|
||||
"Supabase didn't meet my needs",
|
||||
'Dashboard is too complicated',
|
||||
'Postgres is too complicated',
|
||||
'Problem not solved',
|
||||
'Bug issues',
|
||||
'I decided to use something else',
|
||||
'My work has finished/discontinued',
|
||||
'I’m migrating to/starting a new project',
|
||||
'None of the above',
|
||||
]
|
||||
|
||||
export const USAGE_BASED_PRODUCTS = [
|
||||
{
|
||||
title: 'Database',
|
||||
icon: <IconDatabase className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [
|
||||
{
|
||||
key: 'db_size',
|
||||
attribute: 'total_db_size_bytes',
|
||||
title: 'Database space',
|
||||
units: 'bytes',
|
||||
costPerUnit: 0.125,
|
||||
},
|
||||
{
|
||||
key: 'db_egress',
|
||||
attribute: 'total_egress_modified',
|
||||
title: 'Database egress',
|
||||
units: 'bytes',
|
||||
costPerUnit: 0.09,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Auth',
|
||||
icon: <IconKey className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [
|
||||
{
|
||||
key: 'monthly_active_users',
|
||||
attribute: 'total_auth_billing_period_mau',
|
||||
title: 'Monthly Active Users',
|
||||
units: 'absolute',
|
||||
costPerUnit: 0.00325,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
icon: <IconArchive className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [
|
||||
{
|
||||
key: 'storage_size',
|
||||
attribute: 'total_storage_size_bytes',
|
||||
title: 'Storage space',
|
||||
units: 'bytes',
|
||||
costPerUnit: 0.021,
|
||||
},
|
||||
{
|
||||
key: 'storage_egress',
|
||||
attribute: 'total_storage_egress',
|
||||
title: 'Storage egress',
|
||||
units: 'bytes',
|
||||
costPerUnit: 0.09,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ChargeableProduct } from './PAYGUsage.types'
|
||||
|
||||
export const chargeableProducts: ChargeableProduct[] = [
|
||||
{
|
||||
title: 'Database',
|
||||
iconUrl: '/img/database.svg',
|
||||
features: [
|
||||
{
|
||||
title: 'Database Size',
|
||||
attribute: 'total_db_size_bytes',
|
||||
costPerUnit: 0.125,
|
||||
unitQuantity: 1000000000, // 1GB
|
||||
pricingModel: 'max',
|
||||
freeQuota: 8000000000, // 8GB
|
||||
},
|
||||
{
|
||||
title: 'Database Egress',
|
||||
attribute: 'total_egress_modified',
|
||||
costPerUnit: 0.09,
|
||||
unitQuantity: 1000000000, // 1GB
|
||||
pricingModel: 'sum',
|
||||
freeQuota: 50000000000, // 50GB
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
iconUrl: '/img/archive.svg',
|
||||
features: [
|
||||
{
|
||||
title: 'Storage Size',
|
||||
attribute: 'total_storage_size_bytes',
|
||||
costPerUnit: 0.021,
|
||||
unitQuantity: 1000000000, // 1GB
|
||||
pricingModel: 'max',
|
||||
freeQuota: 100000000000, // 100GB
|
||||
},
|
||||
{
|
||||
title: 'Storage Egress',
|
||||
attribute: 'total_storage_egress',
|
||||
costPerUnit: 0.09,
|
||||
unitQuantity: 1000000000, // 1GB
|
||||
pricingModel: 'sum',
|
||||
freeQuota: 200000000000, // 200GB
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { FC } from 'react'
|
||||
import { FC } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
|
||||
import { ChargeableProduct, PaygStats, ProductFeature } from './PAYGUsage.types'
|
||||
import { deriveProductCost } from './PAYGUsage.utils'
|
||||
import { chargeableProducts } from './PAYGUsage.constants'
|
||||
import { DATE_FORMAT } from 'lib/constants'
|
||||
import { USAGE_BASED_PRODUCTS } from '../Billing.constants'
|
||||
|
||||
interface Props {
|
||||
paygStats: PaygStats
|
||||
dateRange: {
|
||||
period_start: { date: string; time_period: string }
|
||||
period_end: { date: string; time_period: string }
|
||||
@@ -16,16 +13,14 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
const PAYGUsage: FC<Props> = ({ paygStats, dateRange }) => {
|
||||
const PAYGUsage: FC<Props> = ({ dateRange }) => {
|
||||
const startDate =
|
||||
dateRange.period_start.date ?? dayjs().utc().startOf('month').format(DATE_FORMAT)
|
||||
const endDate = dateRange.period_end.date ?? dayjs().utc().endOf('month').format(DATE_FORMAT)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{chargeableProducts.map((product: ChargeableProduct) => {
|
||||
const productCost = deriveProductCost(paygStats, product)
|
||||
|
||||
{USAGE_BASED_PRODUCTS.map((product) => {
|
||||
return (
|
||||
<div
|
||||
key={product.title}
|
||||
@@ -36,21 +31,15 @@ const PAYGUsage: FC<Props> = ({ paygStats, dateRange }) => {
|
||||
<div className="flex items-center justify-between overflow-hidden rounded px-6 py-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded bg-white">
|
||||
<img width={'16'} src={product.iconUrl} />
|
||||
{product.icon}
|
||||
</div>
|
||||
<h5 className="mb-0 font-medium">{product.title}</h5>
|
||||
</div>
|
||||
{product.title !== 'Authentication' && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<p className="text-scale-1000">$</p>
|
||||
<h4 className="text-lg m-0 font-medium">{productCost.toFixed(3)}</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{product.features.map((feature: ProductFeature) => (
|
||||
{product.features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="border-panel-border-light dark:border-panel-border-dark relative border-t px-6 py-3"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface ChargeableProduct {
|
||||
title: string
|
||||
iconUrl: string
|
||||
features: ProductFeature[]
|
||||
}
|
||||
|
||||
export interface ProductFeature {
|
||||
title: string
|
||||
attribute: string
|
||||
costPerUnit: number
|
||||
unitQuantity: number
|
||||
pricingModel: PricingModel
|
||||
freeQuota?: number
|
||||
}
|
||||
|
||||
export type PaygStats = { [key: string]: { sum: number; max: number } }
|
||||
|
||||
export type PricingModel = 'sum' | 'max'
|
||||
@@ -1,39 +0,0 @@
|
||||
import { get, maxBy, sum } from 'lodash'
|
||||
import { ChargeableProduct, PaygStats, ProductFeature } from './PAYGUsage.types'
|
||||
|
||||
/**
|
||||
*
|
||||
* Derives the most appropriate cost
|
||||
* depending on what kind of product feature we are displaying cost for.
|
||||
*
|
||||
* @param paygStats
|
||||
* @param feature
|
||||
*
|
||||
* @returns number
|
||||
*/
|
||||
export const deriveFeatureCost = (paygStats: PaygStats | undefined, feature: ProductFeature) => {
|
||||
let rawUsage = paygStats?.[feature.attribute]?.[feature.pricingModel]
|
||||
|
||||
/**
|
||||
* Some features have a free quota
|
||||
*
|
||||
* Check there is included free quota,
|
||||
* and remove free quota amount from the raw usage derived from paygStats
|
||||
*
|
||||
* if the number is negative, then Math.max should return 0
|
||||
*
|
||||
* todo: move this logic to backend @mildtomato
|
||||
*/
|
||||
if (rawUsage && feature.freeQuota) {
|
||||
rawUsage = Math.max(0, rawUsage - feature.freeQuota)
|
||||
}
|
||||
|
||||
const maximumValueOfTheMonth = rawUsage ?? 0
|
||||
|
||||
return (maximumValueOfTheMonth / feature.unitQuantity) * feature.costPerUnit
|
||||
}
|
||||
|
||||
export const deriveProductCost = (paygStats: any, product: ChargeableProduct) => {
|
||||
const costs = product.features.map((feature) => deriveFeatureCost(paygStats, feature))
|
||||
return sum(costs)
|
||||
}
|
||||
@@ -6,17 +6,17 @@ interface Props {
|
||||
amount: number | string
|
||||
unitPrice: number | string
|
||||
price: number | string
|
||||
freeQuota?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
const CostBreakdownRow: FC<Props> = ({ item, amount, unitPrice, price, freeQuota }) => {
|
||||
const CostBreakdownRow: FC<Props> = ({ item, amount, unitPrice, price, note }) => {
|
||||
return (
|
||||
<div className="relative flex items-center border-t border-panel-border-light px-6 py-3 dark:border-panel-border-dark">
|
||||
<div className="flex w-[40%] items-center gap-3">
|
||||
<span className="text-sm">{item}</span>
|
||||
{freeQuota && (
|
||||
{note && (
|
||||
<Badge color="scale">
|
||||
<span className="text-xs text-scale-900">{`${freeQuota / 1000000000}GB included`}</span>
|
||||
<span className="text-xs text-scale-900">{note}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FC } from 'react'
|
||||
import { sum } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, Loading } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useStore, useFlag } from 'hooks'
|
||||
import { checkPermissions, useStore, useFlag, useProjectUsage } from 'hooks'
|
||||
import { STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { formatBytes } from 'lib/helpers'
|
||||
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { chargeableProducts } from '../PAYGUsage/PAYGUsage.constants'
|
||||
import { PaygStats, ProductFeature } from '../PAYGUsage/PAYGUsage.types'
|
||||
import { deriveFeatureCost, deriveProductCost } from '../PAYGUsage/PAYGUsage.utils'
|
||||
import CostBreakdownRow from './CostBreakdownRow'
|
||||
import { StripeSubscription } from './Subscription.types'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants'
|
||||
|
||||
interface Props {
|
||||
project: any
|
||||
subscription: StripeSubscription
|
||||
paygStats: PaygStats | undefined
|
||||
loading?: boolean
|
||||
showProjectName?: boolean
|
||||
currentPeriodStart: number
|
||||
@@ -31,7 +27,6 @@ interface Props {
|
||||
const Subscription: FC<Props> = ({
|
||||
project,
|
||||
subscription,
|
||||
paygStats,
|
||||
loading = false,
|
||||
showProjectName = false,
|
||||
currentPeriodStart,
|
||||
@@ -40,6 +35,7 @@ const Subscription: FC<Props> = ({
|
||||
const { ui } = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { ref } = router.query
|
||||
const enablePermissions = useFlag('enablePermissions')
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
|
||||
@@ -48,6 +44,8 @@ const Subscription: FC<Props> = ({
|
||||
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions')
|
||||
: ui.selectedOrganization?.is_owner
|
||||
|
||||
const { usage, isLoading: loadingUsage } = useProjectUsage(ref as string)
|
||||
|
||||
const isPayg = subscription?.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG
|
||||
const isEnterprise = subscription.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
|
||||
|
||||
@@ -56,15 +54,23 @@ const Subscription: FC<Props> = ({
|
||||
const basePlanCost = subscription?.tier.unit_amount / 100
|
||||
|
||||
const deriveTotalCost = (): number => {
|
||||
const totalAddOnCost = sum(addOns.map((addOn) => addOn.unit_amount / 100))
|
||||
const totalUsageCost = isPayg
|
||||
? Number(sum(chargeableProducts.map((product) => deriveProductCost(paygStats, product))))
|
||||
: 0
|
||||
const totalAddOnCost = addOns
|
||||
.map((addOn) => addOn.unit_amount / 100)
|
||||
.reduce((prev, current) => prev + current, 0)
|
||||
const totalUsageCost =
|
||||
usage === undefined
|
||||
? 0
|
||||
: Object.keys(usage)
|
||||
.map((productKey) => {
|
||||
return usage[productKey].cost
|
||||
})
|
||||
.reduce((prev, current) => prev + current, 0)
|
||||
|
||||
return basePlanCost + totalAddOnCost + totalUsageCost
|
||||
}
|
||||
|
||||
return (
|
||||
<Loading active={loading}>
|
||||
<Loading active={loading || loadingUsage}>
|
||||
<div className="mb-8 w-full overflow-hidden rounded border border-panel-border-light dark:border-panel-border-dark">
|
||||
<div className="bg-panel-body-light dark:bg-panel-body-dark">
|
||||
<div className="flex items-center justify-between px-6 pt-4">
|
||||
@@ -164,8 +170,8 @@ const Subscription: FC<Props> = ({
|
||||
<CostBreakdownRow
|
||||
item="Base Plan"
|
||||
amount={1}
|
||||
unitPrice={basePlanCost}
|
||||
price={basePlanCost}
|
||||
unitPrice={basePlanCost.toFixed(2)}
|
||||
price={basePlanCost.toFixed(2)}
|
||||
/>
|
||||
{addOns.map((addOn) => (
|
||||
<CostBreakdownRow
|
||||
@@ -177,25 +183,40 @@ const Subscription: FC<Props> = ({
|
||||
/>
|
||||
))}
|
||||
{isPayg &&
|
||||
chargeableProducts.map((product) =>
|
||||
product.features.map((feature: ProductFeature) => {
|
||||
const derivedCost = deriveFeatureCost(paygStats, feature)
|
||||
const cost = derivedCost.toFixed(2)
|
||||
USAGE_BASED_PRODUCTS.map((product) => {
|
||||
return product.features.map((feature) => {
|
||||
const amount = usage?.[feature.key]?.usage ?? 0
|
||||
const limit = usage?.[feature.key]?.limit ?? 0
|
||||
const cost = (usage?.[feature.key]?.cost ?? 0).toFixed(2)
|
||||
|
||||
return (
|
||||
<CostBreakdownRow
|
||||
key={feature.attribute}
|
||||
key={feature.key}
|
||||
item={feature.title}
|
||||
freeQuota={feature.freeQuota}
|
||||
amount={formatBytes(
|
||||
paygStats?.[feature.attribute]?.[feature.pricingModel] ?? 0
|
||||
)}
|
||||
unitPrice={`${feature.costPerUnit}/GB`}
|
||||
amount={
|
||||
feature.units === 'bytes' ? formatBytes(amount) : amount.toLocaleString()
|
||||
}
|
||||
unitPrice={
|
||||
feature.units === 'bytes'
|
||||
? `${feature.costPerUnit}/GB`
|
||||
: feature.costPerUnit
|
||||
}
|
||||
price={cost}
|
||||
note={
|
||||
feature.units === 'bytes'
|
||||
? `${formatBytes(limit)} included`
|
||||
: `${limit.toLocaleString()} included`
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div className="relative flex items-center border-t border-panel-border-light px-6 py-3 dark:border-panel-border-dark">
|
||||
})}
|
||||
<div
|
||||
className={[
|
||||
'relative flex items-center border-t px-6 py-3',
|
||||
'border-panel-border-light dark:border-panel-border-dark',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="w-[80%]">
|
||||
<p className="text-sm text-scale-1100">
|
||||
Estimated cost for {dayjs.unix(currentPeriodStart).utc().format('MMM D, YYYY')}{' '}
|
||||
|
||||
@@ -12,7 +12,6 @@ import UpdateSuccess from './UpdateSuccess'
|
||||
import Invoices from './Invoices'
|
||||
|
||||
export * from './Subscription/Subscription.types'
|
||||
export * from './PAYGUsage/PAYGUsage.constants'
|
||||
export {
|
||||
PAYGUsage,
|
||||
Subscription,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { IconArchive, IconDatabase, IconKey } from 'ui'
|
||||
|
||||
export const usageBasedItems = [
|
||||
{
|
||||
title: 'Database',
|
||||
icon: <IconDatabase className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [
|
||||
{ key: 'db_size', title: 'Database space', units: 'bytes' },
|
||||
{ key: 'db_egress', title: 'Database egress', units: 'bytes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Auth',
|
||||
icon: <IconKey className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [{ key: 'monthly_active_users', title: 'Monthly Active Users', units: 'absolute' }],
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
icon: <IconArchive className="dark:text-scale-100" size={16} strokeWidth={2} />,
|
||||
features: [
|
||||
{ key: 'storage_size', title: 'Storage space', units: 'bytes' },
|
||||
{ key: 'storage_egress', title: 'Storage egress', units: 'bytes' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -6,8 +6,8 @@ import { formatBytes } from 'lib/helpers'
|
||||
import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { usageBasedItems } from './ProjectUsageBars.constants'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants'
|
||||
|
||||
interface Props {
|
||||
projectRef?: string
|
||||
@@ -48,7 +48,7 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
<Loading active={isLoading}>
|
||||
{usage && (
|
||||
<div>
|
||||
{usageBasedItems.map((product) => {
|
||||
{USAGE_BASED_PRODUCTS.map((product) => {
|
||||
const isExceededUsage =
|
||||
showUsageExceedMessage &&
|
||||
product.features
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ResourceUsage {
|
||||
usage: number
|
||||
limit: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useProjectSubscription, useProjectUsage } from 'hooks'
|
||||
import { formatBytes } from 'lib/helpers'
|
||||
import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import { usageBasedItems } from './ProjectUsageBars.constants'
|
||||
import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants'
|
||||
|
||||
interface ProjectUsageMinimalProps {
|
||||
projectRef?: string
|
||||
@@ -23,7 +23,7 @@ const ProjectUsageMinimal: FC<ProjectUsageMinimalProps> = ({ projectRef, filter
|
||||
return <></>
|
||||
}
|
||||
|
||||
const product = usageBasedItems.find((item) => item.title === filter)
|
||||
const product = USAGE_BASED_PRODUCTS.find((item) => item.title === filter)
|
||||
if (!product) return <></>
|
||||
|
||||
if (usageError || subscriptionError) {
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
|
||||
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
|
||||
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
|
||||
|
||||
export const convertB64toBlob = (image: string) => {
|
||||
const contentType = 'image/png'
|
||||
const byteCharacters = atob(image.substr(`data:${contentType};base64,`.length))
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 1024) {
|
||||
const slice = byteCharacters.slice(offset, offset + 1024)
|
||||
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
const blob = new Blob(byteArrays, { type: contentType })
|
||||
return blob
|
||||
}
|
||||
|
||||
export const uploadAttachment = async (ref: string, image: string) => {
|
||||
const supabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
// @ts-ignore
|
||||
multiTab: false,
|
||||
detectSessionInUrl: false,
|
||||
localStorage: {
|
||||
getItem: (key: string) => undefined,
|
||||
setItem: (key: string, value: string) => {},
|
||||
removeItem: (key: string) => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const blob = convertB64toBlob(image)
|
||||
const name = `${ref || 'no-project'}/${uuidv4()}.png`
|
||||
const options = { cacheControl: '3600' }
|
||||
const { data: file, error: uploadError } = await supabaseClient.storage
|
||||
.from('feedback-attachments')
|
||||
.upload(name, blob, options)
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Failed to upload:', uploadError)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const { data } = await supabaseClient.storage
|
||||
.from('feedback-attachments')
|
||||
.createSignedUrls([file.path], 10 * 365 * 24 * 60 * 60)
|
||||
return data?.[0].signedUrl
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
+103
-11
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect, useRef, FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect, useRef, FC } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, Input, Popover } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { toPng } from 'html-to-image'
|
||||
import { Button, Input, Popover, IconCamera, IconX } from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { convertB64toBlob, uploadAttachment } from './FeedbackDropdown.utils'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -19,7 +22,10 @@ const FeedbackWidget: FC<Props> = ({ onClose }) => {
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [screenshot, setScreenshot] = useState<string>()
|
||||
|
||||
const [isSending, setSending] = useState(false)
|
||||
const [isSavingScreenshot, setIsSavingScreenshot] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef?.current?.focus()
|
||||
@@ -29,15 +35,47 @@ const FeedbackWidget: FC<Props> = ({ onClose }) => {
|
||||
setFeedback(e.target.value)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
onClose()
|
||||
const captureScreenshot = () => {
|
||||
setIsSavingScreenshot(true)
|
||||
|
||||
function filter(node: HTMLElement) {
|
||||
if ((node?.children ?? []).length > 0) {
|
||||
return node.children[0].id !== 'feedback-widget'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
toPng(document.body, { filter })
|
||||
.then((dataUrl: any) => setScreenshot(dataUrl))
|
||||
.catch((error: any) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: 'Failed to capture screenshot',
|
||||
duration: 4000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingScreenshot(false)
|
||||
})
|
||||
}
|
||||
|
||||
const sendFeedback = async () => {
|
||||
if (feedback.length) {
|
||||
if (feedback.length === 0 && screenshot !== undefined) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'Please include a message in your feedback.',
|
||||
duration: 4000,
|
||||
})
|
||||
} else if (feedback.length > 0) {
|
||||
setSending(true)
|
||||
const attachmentUrl = screenshot
|
||||
? await uploadAttachment(ref as string, screenshot)
|
||||
: undefined
|
||||
const formattedFeedback =
|
||||
attachmentUrl !== undefined ? `${feedback}\n\nAttachments:\n${attachmentUrl}` : feedback
|
||||
await post(`${API_URL}/feedback/send`, {
|
||||
message: feedback,
|
||||
message: formattedFeedback,
|
||||
pathname: router.asPath,
|
||||
category: 'Feedback',
|
||||
projectRef: ref,
|
||||
@@ -52,7 +90,7 @@ const FeedbackWidget: FC<Props> = ({ onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div id="feedback-widget">
|
||||
<Input.TextArea
|
||||
className="w-80 p-3"
|
||||
size="small"
|
||||
@@ -64,12 +102,66 @@ const FeedbackWidget: FC<Props> = ({ onClose }) => {
|
||||
<Popover.Seperator />
|
||||
<div className="w-80 space-y-3 px-3 py-2 pb-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<Button type="default" onClick={onCancel} className="hover:border-gray-500">
|
||||
<Button type="default" onClick={onClose} className="hover:border-gray-500">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isSending} loading={isSending} onClick={sendFeedback}>
|
||||
Send feedback
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{screenshot !== undefined ? (
|
||||
<div
|
||||
style={{ backgroundImage: `url("${screenshot}")` }}
|
||||
onClick={() => {
|
||||
const blob = convertB64toBlob(screenshot)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
window.open(blobUrl, '_blank')
|
||||
}}
|
||||
className="cursor-pointer rounded h-[26px] w-[30px] border border-scale-600 relative bg-cover bg-center bg-no-repeat"
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'cursor-pointer rounded-full bg-red-900 h-3 w-3',
|
||||
'flex items-center justify-center absolute -top-1 -right-1',
|
||||
].join(' ')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setScreenshot(undefined)
|
||||
}}
|
||||
>
|
||||
<IconX size={8} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
as="span"
|
||||
type="default"
|
||||
disabled={isSavingScreenshot}
|
||||
loading={isSavingScreenshot}
|
||||
className="px-2 py-1.5"
|
||||
icon={<IconCamera size={14} />}
|
||||
onClick={() => captureScreenshot()}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow', // background
|
||||
'w-[130px] text-center border-scale-200 border', //border
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">
|
||||
Capture screenshot of current view
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Button disabled={isSending} loading={isSending} onClick={sendFeedback}>
|
||||
Send feedback
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-scale-1000">
|
||||
Have a technical issue? Contact{' '}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconEdit2,
|
||||
IconLoader,
|
||||
IconChevronsUp,
|
||||
IconList,
|
||||
} from 'ui'
|
||||
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
|
||||
import { STORAGE_VIEWS, STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER } from '../Storage.constants.ts'
|
||||
@@ -284,11 +285,18 @@ const FileExplorerHeader = ({
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
icon={<IconColumns size={16} strokeWidth={2} />}
|
||||
icon={
|
||||
view === 'LIST' ? (
|
||||
<IconList size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<IconColumns size={16} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
type="text"
|
||||
disabled={breadcrumbs.length === 0}
|
||||
onChange={onChangeView}
|
||||
>
|
||||
Views
|
||||
View as
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useRef, useEffect, useState, FormEvent, KeyboardEvent, ReactNode } from 'react'
|
||||
import { orderBy, filter, without } from 'lodash'
|
||||
import { Popover, IconCheck, IconAlertCircle, IconSearch, IconPlus } from 'ui'
|
||||
import {
|
||||
Popover,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
} from 'ui'
|
||||
|
||||
import { BadgeDisabled, BadgeSelected } from './Badges'
|
||||
|
||||
@@ -223,7 +230,7 @@ export default function MultiSelect({
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'flex w-full flex-wrap items-start items-center gap-1.5 p-1.5',
|
||||
'flex w-full flex-wrap items-start gap-1.5 p-1.5 cursor-default',
|
||||
`${selectedOptions.length === 0 ? 'h-9' : ''}`,
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -250,6 +257,9 @@ export default function MultiSelect({
|
||||
)
|
||||
}
|
||||
})}
|
||||
<div className="absolute inset-y-0 right-0 pl-3 pr-1 flex space-x-1 items-center cursor-pointer ">
|
||||
<IconChevronDown className="text-scale-900" />
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ export * from './useFreeProjectLimitCheck'
|
||||
export * from './usePermissions'
|
||||
export * from './useProfile'
|
||||
export * from './useProjectAuthConfig'
|
||||
export * from './useProjectPaygStatistics'
|
||||
export * from './useProjectSettings'
|
||||
export * from './useProjectSubscription'
|
||||
export * from './useSubscriptionStats'
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import useSWR from 'swr'
|
||||
import { get as _get, maxBy } from 'lodash'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL, DATE_FORMAT, PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { chargeableProducts } from 'components/interfaces/Billing'
|
||||
import { PaygStats } from 'components/interfaces/Billing/PAYGUsage/PAYGUsage.types'
|
||||
|
||||
/**
|
||||
* Get project payg statistics
|
||||
*
|
||||
* @param ref project reference
|
||||
* @param tierProdId tier product supabase_prod_id
|
||||
*/
|
||||
export function useProjectPaygStatistics(ref?: string, tierProdId?: string) {
|
||||
const startDate = dayjs().utc().startOf('month').format(DATE_FORMAT)
|
||||
const endDate = dayjs().utc().endOf('month').format(DATE_FORMAT)
|
||||
const attributes =
|
||||
'total_egress,total_db_size_bytes,total_db_egress_bytes,total_storage_size_bytes,total_storage_egress'
|
||||
const url = `${API_URL}/projects/${ref}/daily-stats?attribute=${attributes}&startDate=${encodeURIComponent(
|
||||
startDate
|
||||
)}&endDate=${encodeURIComponent(endDate)}&interval='1d'`
|
||||
/**
|
||||
* data is a dictionary
|
||||
* {
|
||||
* data: array,
|
||||
* totalAverage: number,
|
||||
* total: number,
|
||||
* maximum: number,
|
||||
* format: string,
|
||||
* }
|
||||
*/
|
||||
const { data, error } = useSWR<any>(
|
||||
ref && tierProdId === PRICING_TIER_PRODUCT_IDS.PAYG ? url : null,
|
||||
get
|
||||
)
|
||||
const anyError = data?.error || error
|
||||
|
||||
return {
|
||||
paygStats: anyError ? undefined : getPaygStats(data?.data),
|
||||
error: anyError,
|
||||
isLoading: !anyError && !data,
|
||||
isError: !!anyError,
|
||||
}
|
||||
}
|
||||
|
||||
function getPaygStats(data: any): PaygStats {
|
||||
const paygStats: any = {}
|
||||
if (!data) return paygStats
|
||||
|
||||
let dataModified: any = []
|
||||
data.map((x: any) => {
|
||||
dataModified.push({
|
||||
...x,
|
||||
total_egress_modified:
|
||||
(x.total_egress == undefined ? 0 : x.total_egress) -
|
||||
(x.total_storage_egress == undefined ? 0 : x.total_storage_egress),
|
||||
})
|
||||
})
|
||||
|
||||
chargeableProducts.forEach((product) => {
|
||||
product.features.forEach((feature) => {
|
||||
// calculate max
|
||||
const max = _get(maxBy(dataModified, feature.attribute), feature.attribute) ?? 0
|
||||
|
||||
// calculate sum
|
||||
let sum = 0
|
||||
dataModified.map((x: any) => {
|
||||
const featureAttribute = x[feature.attribute] == undefined ? 0 : x[feature.attribute]
|
||||
sum = sum + featureAttribute
|
||||
})
|
||||
|
||||
// set sum and max under attribute name
|
||||
paygStats[feature.attribute] = {
|
||||
sum,
|
||||
max,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return paygStats
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default async function apiWrapper(
|
||||
}
|
||||
}
|
||||
|
||||
const func = withSentry(handler)
|
||||
const func = withSentry(handler as any)
|
||||
// @ts-ignore
|
||||
return await func(req, res)
|
||||
} catch (error) {
|
||||
|
||||
+2
-1
@@ -32,13 +32,14 @@
|
||||
"analytics-node": "^3.5.0",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"config": "*",
|
||||
"common": "*",
|
||||
"config": "*",
|
||||
"configcat-js": "^5.6.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dayjs": "^1.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"generate-password": "^1.7.0",
|
||||
"html-to-image": "^1.10.8",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"json-logic-js": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'styles/contextMenu.scss'
|
||||
import 'styles/react-data-grid-logs.scss'
|
||||
import 'styles/date-picker.scss'
|
||||
import 'styles/grid.scss'
|
||||
import 'styles/users-table.scss'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
|
||||
@@ -4,14 +4,13 @@ import { observer } from 'mobx-react-lite'
|
||||
import { Loading, IconArrowRight } from 'ui'
|
||||
|
||||
import { Project, NextPageWithLayout } from 'types'
|
||||
import { useProjectPaygStatistics, useProjectSubscription, useStore } from 'hooks'
|
||||
import { useProjectSubscription, useStore } from 'hooks'
|
||||
import { STRIPE_PRODUCT_IDS, TIME_PERIODS_REPORTS, TIME_PERIODS_BILLING } from 'lib/constants'
|
||||
import { SettingsLayout } from 'components/layouts'
|
||||
import LoadingUI from 'components/ui/Loading'
|
||||
import OveragesBanner from 'components/ui/OveragesBanner/OveragesBanner'
|
||||
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
|
||||
import { PAYGUsage, Subscription, Invoices } from 'components/interfaces/Billing'
|
||||
import { PaygStats } from 'components/interfaces/Billing/PAYGUsage/PAYGUsage.types'
|
||||
import ProjectUsage from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars'
|
||||
|
||||
const ProjectBilling: NextPageWithLayout = () => {
|
||||
@@ -39,9 +38,6 @@ interface SettingsProps {
|
||||
|
||||
const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
const { ui } = useStore()
|
||||
const projectTier = ui.selectedProject?.subscription_tier
|
||||
|
||||
const [dateRange, setDateRange] = useState<any>()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
@@ -49,10 +45,9 @@ const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
error,
|
||||
} = useProjectSubscription(ui.selectedProject?.ref)
|
||||
|
||||
const { paygStats }: { paygStats: PaygStats | undefined } = useProjectPaygStatistics(
|
||||
ui.selectedProject?.ref,
|
||||
subscription?.tier?.supabase_prod_id
|
||||
)
|
||||
const [dateRange, setDateRange] = useState<any>()
|
||||
const projectTier = ui.selectedProject?.subscription_tier
|
||||
const isPayg = subscription?.tier?.prod_id === STRIPE_PRODUCT_IDS.PAYG
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -74,7 +69,6 @@ const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
loading={loading}
|
||||
project={project}
|
||||
subscription={subscription}
|
||||
paygStats={paygStats}
|
||||
currentPeriodStart={subscription?.billing.current_period_start}
|
||||
currentPeriodEnd={subscription?.billing.current_period_end}
|
||||
/>
|
||||
@@ -86,7 +80,7 @@ const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
</div>
|
||||
</div>
|
||||
</Loading>
|
||||
) : subscription?.tier?.prod_id === STRIPE_PRODUCT_IDS.PAYG ? (
|
||||
) : isPayg ? (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center space-x-3">
|
||||
<DateRangePicker
|
||||
@@ -110,7 +104,7 @@ const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{paygStats && dateRange && <PAYGUsage paygStats={paygStats} dateRange={dateRange} />}
|
||||
{dateRange && <PAYGUsage dateRange={dateRange} />}
|
||||
</div>
|
||||
) : (
|
||||
<ProjectUsage projectRef={project?.ref} />
|
||||
|
||||
@@ -32,12 +32,17 @@ span.th.tr.td.textarea {
|
||||
}
|
||||
|
||||
.grid-monaco-editor,
|
||||
.monaco-editor .suggest-widget .main {
|
||||
max-height: none;
|
||||
height: inherit;
|
||||
width: 100%;
|
||||
margin: inherit;
|
||||
padding: inherit;
|
||||
overflow: inherit;
|
||||
background-color: inherit;
|
||||
.monaco-editor .suggest-widget {
|
||||
.main {
|
||||
max-height: none;
|
||||
height: inherit;
|
||||
width: 100%;
|
||||
margin: inherit;
|
||||
padding: inherit;
|
||||
overflow: inherit;
|
||||
}
|
||||
|
||||
.monaco-list-row.focused .main {
|
||||
background: var(--vscode-quickInput-background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.users-table-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--colors-slate9) var(--colors-slate1);
|
||||
|
||||
.dark & {
|
||||
scrollbar-color: var(--colors-slate10) var(--colors-slate3o);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background: var(--colors-slate2);
|
||||
height: 3px;
|
||||
width: 0px;
|
||||
border-radius: 0;
|
||||
|
||||
.dark & {
|
||||
background: var(--colors-slate6);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 6px var(--colors-slate6);
|
||||
-webkit-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
|
||||
.dark & {
|
||||
-webkit-box-shadow: inset 0 0 6px var(--colors-slate8);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
-webkit-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--colors-slate9);
|
||||
|
||||
.dark & {
|
||||
-webkit-box-shadow: inset 0 0 6px var(--colors-slate12);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user