Merge pull request #10601 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-11-29 19:17:43 +08:00
commit d0614755ca
105 files changed
+5256 -889

No files matched your search

+5 -2
View File
@@ -74,11 +74,14 @@ export function SearchProvider({ children }: any) {
appId={APP_ID}
// @ts-ignore
navigator={{
navigate({ suggestionUrl }) {
navigate({ itemUrl }) {
setIsOpen(false)
router.push(suggestionUrl)
router.push(itemUrl)
},
}}
getMissingResultsUrl={({ query }) => {
return `https://github.com/supabase/supabase/issues/new?title=Unable+to+search+docs+with+query:+${query}`
}}
hitComponent={Hit}
transformItems={(items) => {
return items.map((item, index) => {
+5
View File
@@ -118,6 +118,11 @@ const Nav = [
url: '/reference/dart/storage-from-createsignedurl',
items: [],
},
{
name: 'from.createSignedUrls()',
url: '/reference/dart/storage-from-createsignedurls',
items: [],
},
{
name: 'from.getPublicUrl()',
url: '/reference/dart/storage-from-getpublicurl',
@@ -28,6 +28,33 @@ Once you initialize Supabase in your `main()` method, you can access the `Supaba
final supabase = Supabase.instance.client;
```
### Call `initialize()` with custom headers
You can pass `headers` to initialize your Supabase client with customer headers.
Here is an example of passing a custom auth header to Supabase client.
```dart title=main.dart
Future<void> main() async {
await Supabase.initialize(
url: 'https://xyzcompany.supabase.co',
anonKey: 'public-anon-key',
headers: {
'Authorization': 'Bearer $accessToken',
},
);
runApp(const MyApp());
}
```
### Call `initialize()`multiple times
You can initialize Supabase multiple times in your app. When doing so, call `dispose()` each time before you initialize again.
```dart
Supabase.instance.dispose();
await Supabase.initialize(url: 'https://xyzcompany.supabase.co', anonKey: 'public-anon-key');
```
## Other Dart Projects
You can initialize a new Supabase client using the `SupabaseClient()` method.
@@ -117,7 +117,7 @@ const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key
```
By default the API server points to the `public` schema. You can enable other database schemas within the Dashboard.
Go to `Settings > API > Schema` and add the schema which you want to expose to the API.
Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`.
Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema.
+1 -1
View File
@@ -36,7 +36,7 @@
"@algolia/autocomplete-js": "^1.7.2",
"@algolia/autocomplete-plugin-recent-searches": "^1.7.2",
"@apollo/client": "^3.6.6",
"@docsearch/react": "1.0.0-alpha.27",
"@docsearch/react": "^3.3.0",
"@mdx-js/loader": "^1.6.22",
"@mdx-js/react": "^1.6.22",
"@next/mdx": "^12.0.4",
+20 -8
View File
@@ -55,31 +55,43 @@ You can enable third-party providers with the click of a button by navigating to
When using third-party providers, the [Supabase client library](/docs/reference/javascript/auth-signinwithoauth#sign-in-using-a-third-party-provider-with-redirect) redirects the user to the provider. When the third-party provider successfully authenticates the user, the provider redirects the user to the Supabase Auth callback URL where they are further redirected to the URL specified in the `redirectTo` parameter. This parameter defaults to the [`SITE_URL`](/docs/reference/auth/config#site_url). You can modify the `SITE_URL` or [add additional redirect URLs](https://app.supabase.com/project/_/auth/settings).
You can use wildcard match patterns to support preview URLs from providers like Netlify and Vercel. See the [full list of supported patterns](https://pkg.go.dev/github.com/gobwas/glob#Compile).
You can use wildcard match patterns to support preview URLs from providers like Netlify and Vercel. See the [full list of supported patterns](https://pkg.go.dev/github.com/gobwas/glob#Compile). Use [this tool](https://www.digitalocean.com/community/tools/glob?comments=true&glob=http%3A%2F%2Flocalhost%3A3000%2F%2A%2A&matches=false&tests=http%3A%2F%2Flocalhost%3A3000&tests=http%3A%2F%2Flocalhost%3A3000%2F&tests=http%3A%2F%2Flocalhost%3A3000%2F%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest-test%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest%2Ftest%3Ftest%3Dtest) to test your patterns.
> ⚠️ WARNING: While the "globstar" (`**`) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production.
#### Netlify preview URLs
For deployments with Netlify, set the `SITE_URL` to your official site URL. Add the following additional redirect URLs for local development and deployment previews:
- `http://localhost:3000/*/*`
- `https://**--my_org.netlify.app/*`
- `http://localhost:3000/**`
- `https://**--my_org.netlify.app/**`
#### Vercel preview URLs
For deployments with Vercel, set the `SITE_URL` to your official site URL. Add the following additional redirect URLs for local development and deployment previews:
- `http://localhost:3000/*/*`
- `https://**vercel.app/*/*`
- `http://localhost:3000/**`
- `https://**.vercel.app/**`
Vercel provides an environment variable for the URL of the deployment called `NEXT_PUBLIC_VERCEL_URL`. See the [Vercel docs](https://vercel.com/docs/concepts/projects/environment-variables#system-environment-variables) for more details. You can use this variable to dynamically redirect depending on the environment:
```js
const getURL = () => {
let url =
process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
'http://localhost:3000/';
// Make sure to include `https://` when not localhost.
url = url.includes('http') ? url : `https://${url}`;
// Make sure to including trailing `/`.
url = url.charAt(url.length - 1) === '/' ? url : `${url}/`;
return url;
};
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github'
options: {
redirectTo: process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: "http://localhost:3000"
redirectTo: getURL()
}
}
```
+1 -1
View File
@@ -85,7 +85,7 @@ Read about resetting your database password [here](/docs/guides/database/managin
## Next steps
- Read more about [Postgres](/docs/postgres/server/about)
- Read more about [Postgres](https://www.postgresql.org/about/)
- Sign in: [app.supabase.com](https://app.supabase.com)
export const Page = ({ children }) => <Layout meta={meta} children={children} />
+41 -9
View File
@@ -4,6 +4,7 @@ const path = require('path')
const matter = require('gray-matter')
const dotenv = require('dotenv')
const algoliasearch = require('algoliasearch/lite')
const { isEmpty } = require('lodash')
// [Joshen] We initially thought of building out our search using Algolia directly,
// but eventually decided to just use DocSearch since it provides us a UI and some
@@ -17,12 +18,20 @@ const algoliasearch = require('algoliasearch/lite')
// A lot of them are not even linked to within the docs site, so just need to
// double check if they can be removed or if we want them in the side bars.
const ignoredFiles = [
'docs/404.mdx',
'docs/support.mdx',
'docs/faqs.mdx',
'docs/guides.mdx',
'docs/guides/database/arrays.mdx',
'docs/guides/database/json.mdx',
'pages/404.mdx',
'pages/faq.mdx',
'pages/support.mdx',
'pages/oss.tsx',
'pages/_app.tsx',
'pages/_document.tsx',
'pages/[...slug].tsx',
'pages/company/aup.md',
'pages/company/privacy.md',
'pages/company/sla.md',
'pages/company/terms.md',
'pages/handbook/contributing.mdx',
'pages/handbook/introduction.mdx',
'pages/handbook/supasquad.mdx',
]
const nameMap = {
@@ -69,16 +78,39 @@ async function walk(dir) {
)
const index = client.initIndex(indexName)
const slugs = (await walk('docs')).filter((slug) => !ignoredFiles.includes(slug))
const referencePages = await walk('docs')
const guidePages = (await walk('pages')).filter((slug) => !ignoredFiles.includes(slug))
const allPages = guidePages.concat(referencePages)
const searchObjects = slugs
const searchObjects = allPages
.map((slug) => {
let id, title, description
const fileContents = fs.readFileSync(slug, 'utf8')
const { data, content } = matter(fileContents)
const { id, title, description } = data
if (isEmpty(data)) {
// Guide pages do not have front-matter meta, unlike reference pages, have to manually extract
const metaIndex = fileContents.indexOf('export const meta = {')
if (metaIndex !== -1) {
const metaString =
fileContents
.slice(metaIndex + 20, fileContents.indexOf('}') + 1)
.replace(/\n/g, '')
.slice(0, -2) + '}'
const meta = eval(`(${metaString})`)
id = meta.id
title = meta.title
description = meta.description
}
} else {
id = data.id
title = data.title
description = data.description
}
const url = (slug.includes('/generated') ? slug.replace('/generated', '') : slug)
.replace('docs', '')
.replace('pages', '')
.replace(/\.mdx$/, '')
const source = slug.includes('/reference') ? 'reference' : 'guide'
const object = {
+19 -2
View File
@@ -384,8 +384,25 @@
@apply text-slate-1200 dark:text-scale-1200 font-normal;
}
.DocSearch-StartScreen .DocSearch-Help {
@apply py-16 px-6 text-center text-slate-400;
.DocSearch-NoResults-Prefill-List {
.DocSearch-Help {
color: var(--colors-scale11) !important;
}
}
.DocSearch-StartScreen {
.DocSearch-Help {
@apply text-center py-16;
}
}
.DocSearch-Help {
@apply text-scale-1100;
font-size: 0.875rem !important;
a {
color: var(--colors-brand9) !important;
font-weight: 400 !important;
}
}
.DocSearch-NoResults {
@@ -52,7 +52,7 @@ flutter_bloc: ^8.0.0
Since the app has evolved, we also need to update our table schema. In order to store rooms data, we will add a rooms table. We will also modify the messages table to add a foreign key constraint to the rooms table so that we can tell which message belongs to which room.
We will also intorudce a `create_new_room` function, which is a [database function](https://supabase.com/docs/guides/database/functions) that handles chat room creation. It knows to create a new room if a chat room with the two users does not exist yet, or to just return the room ID if it already exists.
We will also introduce a `create_new_room` function, which is a [database function](https://supabase.com/docs/guides/database/functions) that handles chat room creation. It knows to create a new room if a chat room with the two users does not exist yet, or to just return the room ID if it already exists.
```sql
-- *** Table definitions ***
@@ -1154,7 +1154,7 @@ import 'package:my_chat_app/pages/rooms_page.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Page to redirect users to the appropreate page depending on the initial auth state
/// Page to redirect users to the appropriate page depending on the initial auth state
class SplashPage extends StatefulWidget {
const SplashPage({Key? key}) : super(key: key);
@@ -0,0 +1,336 @@
---
title: SQL or NoSQL? Why not use both (with PostgreSQL)?
description: How to turn Postgres into an easy-to-use NoSQL database that retains all the power of SQL
author: burggraf
image: 2022-11-24-sql-nosql-postgresql/sql-nosql-postgresql.jpg
thumb: 2022-11-24-sql-nosql-postgresql/sql-nosql-postgresql.jpg
tags:
- postgres
date: '2022-11-24'
toc_depth: 3
---
It's a tough decision for any developer starting a new project. Should you store your data in a standard, time-tested SQL database, or go with one of the newer NoSQL document-based databases? This seemingly simple decision can literally make or break your project down the line. Choose correctly and structure your data well, and you may sail smoothly into production and watch your app take off. Make the wrong choice and you could be headed for nightmares (and maybe even some major re-writes) before your app ever makes it out the door.
## Simplicity vs Power
There are tradeoffs with both SQL and NoSQL solutions. Typically it's easier to get started with NoSQL data structures, especially when the data is complex or hierarchical. You can just take a JSON data object from your front-end code and throw it in the database and be done with it. But later when you need to access that data to answer some basic business questions, it's much more difficult. A SQL solution makes it easier to gather data and draw conclusions down the line. Let's look at an example:
Each day I track the food I eat, along with the number of calories in each item:
| Day | Food Item | Calories | Meal |
|--------|-----------|----------|------|
| 01 Jan | Apple | 72 | Breakfast |
| 01 Jan | Oatmeal | 146 | Breakfast |
| 01 Jan | Sandwich | 445 | Lunch |
| 01 Jan | Chips | 280 | Lunch |
| 01 Jan | Cookie | 108 | Lunch |
| 01 Jan | Mixed Nuts | 175 | Snack |
| 01 Jan | Pasta/Sauce | 380 | Dinner |
| 01 Jan | Garlic Bread | 200 | Dinner |
| 01 Jan | Broccoli | 32 | Dinner |
I also track the number of cups of water I drink and when I drink them:
| Day | Time | Cups |
|--------|-------|------|
| Jan 01 | 08:15 | 1 |
| Jan 01 | 09:31 | 1 |
| Jan 01 | 10:42 | 2 |
| Jan 01 | 12:07 | 2 |
| Jan 01 | 14:58 | 1 |
| Jan 01 | 17:15 | 1 |
| Jan 01 | 18:40 | 1 |
| Jan 01 | 19:05 | 1 |
And finally, I track my exercise:
| Day | Time | Duration | Exercise |
|--------|-------|----------|----------|
| Jan 01 | 11:02 | 0.5 | Walking |
| Jan 02 | 09:44 | 0.75 | Bicycling |
| Jan 02 | 17:00 | 0.25 | Walking |
For each day I also track my current weight along with any notes for the day:
| Day | Weight | Notes |
|--------|--------|-------|
| Jan 01 | 172.6 | This new diet is awesome! |
| Jan 14 | 170.2 | Not sure all this is worth it. |
| Jan 22 | 169.8 | Jogged past a McDonald's today. It was hard. |
| Feb 01 | 168.0 | I feel better, but sure miss all that greasy food. |
## Gathering All That Data
That's a lot of different data that needs to be gathered, stored, retrieved, and later analyzed. It's organized simply and easily, but the number of records varies from day to day. On any given day I may have zero or more entries for food, water, and exercise, and I may have zero or one entry for weight & notes.
In my app, I gather all the data for a single day on one page, to make it easier for my users. So, I get a JSON object for each day that looks like this:
```json
{ "date": "2022-01-01",
"weight": 172.6,
"notes": "This new diet is awesome!",
"food": [
{ "title": "Apple", "calories": 72, "meal": "Breakfast"},
{ "title": "Oatmeal", "calories": 146, "meal": "Breakfast"},
{ "title": "Sandwich", "calories": 445, "meal": "Lunch"},
{ "title": "Chips", "calories": 280, "meal": "Lunch"},
{ "title": "Cookie", "calories": 108, "meal": "Lunch"},
{ "title": "Mixed Nuts", "calories": 175, "meal": "Snack"},
{ "title": "Pasta/Sauce", "calories": 380, "meal": "Dinner"},
{ "title": "Garlic Bread", "calories": 200, "meal": "Dinner"},
{ "title": "Broccoli", "calories": 32, "meal": "Dinner"}
],
"water": [
{"time": "08:15", "qty": 1},
{"time": "09:31", "qty": 1},
{"time": "10:42", "qty": 2},
{"time": "10:42", "qty": 2},
{"time": "12:07", "qty": 1},
{"time": "14:58", "qty": 1},
{"time": "17:15", "qty": 1},
{"time": "18:40", "qty": 1},
{"time": "19:05", "qty": 1}
],
"exercise": [
{"time": "11:02", "duration": 0.5, "type": "Walking"}
]
}
```
## Saving the Data
Once we've gathered all the data for a day, we need to store it in our database. In a NoSQL database, this can be a pretty easy process, as we can just create a record (document) for a specific user for a specific date and throw document into a collection and we're done. With SQL, we have some structure we have to work within, and in this case it looks like 4 separate tables: food, water, exercise, and notes. We'd want to do 4 separate inserts here, one for each table. If we don't have data for a specific table (say no exercise was recorded today) then we can skip that table.
If you're using SQL to store this data, you might want to save each table's data as it's entered in your data entry form (and not wait until all the data is entered.) Or you might want to create a database function that takes all the JSON data, parses it, and writes it to all the related tables in a single transaction. There's a lot of ways to handle this, but suffice it to say this: it's a bit more complicated than saving the data in a NoSQL database.
### Retrieving the Data
If we want to display all the data for a single day, it's pretty much the same. With NoSQL you can grab the data for the user's day and then use it in your application. Nice! With SQL we need to query 4 tables to get all the data (or we could use a function to get it all in a single call.) Of course, when displaying the data, we'd need to first break up our JSON data into pieces that are needed by each section of our dashboard screen, and you could argue that it's simpler to map each SQL table with the dashboard section on the screen, but that's a trivial point.
### Analyzing the Data
Now that we've saved the data and we can retrieve it and display it, let's use it for some analysis. *Let's display a graph of how many total calories I've eaten over the past month.* With SQL, this is a simple task:
```sql
select
date, sum(calories) as total_calories
from food_log
group by date
where user_id = 'xyz'
and day between '2022-01-01' and '2022-01-31'
order by date;
```
Bam! Done! Now we can send those results to our graphing library and make a nice pretty picture of my eating habits.
But if we've stored this data in NoSQL, it gets a little more complicated. We'll need to:
- grab all the data for the user for the month
- parse each day's data to get the food log information
- loop through each day and total the calories
- send the aggregate data to our graphing module
If this is something we're going to do regularly, it makes sense to calculate the total calories for each day and store it in the day's document so we can get at that data faster. But that requires more work up front, and we still need to pull the data for each day and parse out that calorie total first. And if we update the data we still need to recalculate things and update that total. Eventually we'll want to do that with the water and exercise totals as well. The code will eventually start to get longer and more complex.
### SQL and NoSQL Together - FTW
Let's see how we can use the power of SQL together with the ease-of-use of NoSQL in the same database to make this all a bit easier. We'll create a table for each day of data (for each user) and store the basic fields such as `weight` and `notes` first. Then we'll just throw the `food_log`, `water_log`, and `exercise_log` fields in a `JSONB` field.
```sql
CREATE TABLE calendar (
id uuid DEFAULT uuid_generate_v4() NOT NULL,
date date,
user_id uuid NOT NULL,
weight numeric,
notes text,
food_log jsonb,
water_log jsonb,
exercise_log jsonb
);
-- (Optional) - create a foreign key relationship for the user_id field
ALTER TABLE ONLY calendar
ADD CONSTRAINT calendar_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id);
```
Now let's insert some data into the table. PostgreSQL offers both JSON and JSONB fields, and since the latter are more optimized by the database and much faster for query processing, we’ll almost always want to use JSONB. We’ll use JSONB fields for `food_log`, `water_log`, and `exercise_log` and just dump the data we got from our app right into those fields as a string:
```sql
insert into calendar (date, user_id, weight, notes, food_log, water_log, exercise_log)
values (
'2022-01-01',
'xyz',
172.6,
'This new diet is awesome!',
'[
{ "title": "Apple", "calories": 72, "meal": "Breakfast"},
{ "title": "Oatmeal", "calories": 146, "meal": "Breakfast"},
{ "title": "Sandwich", "calories": 445, "meal": "Lunch"},
{ "title": "Chips", "calories": 280, "meal": "Lunch"},
{ "title": "Cookie", "calories": 108, "meal": "Lunch"},
{ "title": "Mixed Nuts", "calories": 175, "meal": "Snack"},
{ "title": "Pasta/Sauce", "calories": 380, "meal": "Dinner"},
{ "title": "Garlic Bread", "calories": 200, "meal": "Dinner"},
{ "title": "Broccoli", "calories": 32, "meal": "Dinner"}
]',
'[
{"time": "08:15", "qty": 1},
{"time": "09:31", "qty": 1},
{"time": "10:42", "qty": 2},
{"time": "10:42", "qty": 2},
{"time": "12:07", "qty": 1},
{"time": "14:58", "qty": 1},
{"time": "17:15", "qty": 1},
{"time": "18:40", "qty": 1},
{"time": "19:05", "qty": 1}
]',
'[
{"time": "11:02", "duration": 0.5, "type": "Walking"}
]'
);
```
While that's a big insert statement, it sure beats doing inserts on 4 separate tables. With all those food entries and water log entries, we would have had to made 1 entry in the main table, then 9 food_log entries, 9 water_log entries, and one exercise_log entry for a total of 20 database records. We've wrapped that into a single record.
## But How Do We Query This Data?
Great, we're collecting the data now, and it's easy to insert the data into the database. Editing the data isn't too bad either because we're just downloading the data to the client, updating the JSON field(s) as needed, and throwing them back into the database. Not too hard. But how can I query this data? What about that task from before? *Let's display a graph of how many total calories I've eaten over the past month.*
In this case, that data is stored inside the `food_log` field inside the `calendar` table. If only PostgreSQL had a way of converting JSONB arrays into individual database records (recordsets). Well, it does! The `jsonb_array_elements` function will do this for us, allowing to create a simple table we can use to calculate our caloric intake.
Here's some SQL to turn that `food_log` array into individual output records:
```sql
select
user_id,
date,
jsonb_array_elements(food_log)->>'title' as title,
jsonb_array_elements(food_log)->'calories' as calories,
jsonb_array_elements(food_log)->'meal' as meal
from calendar
where user_id = 'xyz'
and date between
'2022-01-01' and '2022-01-31';
```
This returns a table that looks like this:
| date | title | calories | meal |
| ---------- | ------------ | -------- | --------- |
| 2022-01-01 | Apple | 72 | Breakfast |
| 2022-01-01 | Oatmeal | 146 | Breakfast |
| 2022-01-01 | Sandwich | 445 | Lunch |
| 2022-01-01 | Chips | 280 | Lunch |
| 2022-01-01 | Cookie | 108 | Lunch |
| 2022-01-01 | Mixed Nuts | 175 | Snack |
| 2022-01-01 | Pasta/Sauce | 380 | Dinner |
| 2022-01-01 | Garlic Bread | 200 | Dinner |
| 2022-01-01 | Broccoli | 32 | Dinner |
A couple things to note:
- `jsonb_array_elements(food_log)->>'title' as title` this returns a text field, since the `->>` operator returns TEXT
- `jsonb_array_elements(food_log)->'calories' as calories` this returns a JSON object, since the `->` operator return JSON
If we want to `sum` the calories to get some totals, we can't have a JSON object, so we need to cast that to something more useful, like an `INTEGER`:
- `(jsonb_array_elements(food_log)->'calories')::INTEGER as calories` this returns an INTEGER
Now we can't just throw the `sum` operator on this to get the total calories by day. If we try this:
```sql
select
date,
sum((jsonb_array_elements(food_log)->'calories')::integer) as total_calories
from calendar where user_id = 'xyz'
and date between '2022-01-01' and '2022-01-31'
group by date;
```
we get an error back from PostgreSQL: **Failed to run sql query: aggregate function calls cannot contain set-returning function calls**.
Instead, we need to think of this as a set of building blocks, where our first SQL statement returns a table:
```sql
select
date,
(jsonb_array_elements(food_log)->'calories')::integer as calories
from calendar where user_id = 'xyz'
and date between
'2022-01-01' and '2022-01-31';
```
Now we can take that "table" statement, throw some (parenthesis) around it, and query **it**:
```sql
with data as
(
select
date,
(jsonb_array_elements(food_log)->'calories')::integer as calories
from calendar
where user_id = 'xyz'
and date between
'2022-01-01' and '2022-01-31'
)
select date, sum(calories) from
data group by date;
```
This gives us exactly what we want:
| date | sum |
| ---------- | ---- |
| 2022-01-01 | 1838 |
If we add more data for the rest of the days of the month, we'll have all the data we need for a beautiful graph.
### Searching the Data
What if we want to answer the question: *How many calories were in the garlic bread I ate last month?* This data is stored inside the `food_log` field in the `calendar` table. We can use the same type of query we used before to "flatten" the `food_log` data so we can search it.
To get every item I ate during the month of January, we can use:
```sql
select
date,
jsonb_array_elements(food_log)->>'title' as title,
(jsonb_array_elements(food_log)->'calories')::integer as calories
from calendar
where user_id = 'xyz'
and date between
'2022-01-01' and '2022-01-31'
```
Now to search for the **garlic bread** we can just put (parenthesis) around this to make a "table" and then search for the item we want:
```sql
with my_food as
(
select
date,
jsonb_array_elements(food_log)->>'title' as title,
(jsonb_array_elements(food_log)->'calories')::integer as calories
from calendar
where user_id = 'xyz'
and date between
'2022-01-01' and '2022-01-31'
)
select title, calories
from my_food
where title = 'Garlic Bread';
```
which gives us:
| title | calories |
| ------------ | -------- |
| Garlic Bread | 200 |
## Conclusion
If we take a little time to study the [JSON Functions and Operators](https://www.postgresql.org/docs/9.5/functions-json.html) that PostgreSQL offers, we can turn Postgres into an easy-to-use NoSQL database that still retains all the power of SQL. This gives us a super easy way to store our complex JSON data coming from our application code in our database. Then we can use powerful SQL capabilities to analyze and present that data in our application. It's the best of both worlds!
## More Postgres resources
- [Postgres Full Text Search vs the rest](https://supabase.com/blog/postgres-full-text-search-vs-the-rest)
- [Postgres WASM by Snaplet and Supabase](https://supabase.com/blog/postgres-wasm)
- [Choosing a Postgres Primary Key](https://supabase.com/blog/choosing-a-postgres-primary-key)
- [Partial data dumps using Postgres Row Level Security](https://supabase.com/blog/partial-postgresql-data-dumps-with-rls)
- [Postgres Views](https://supabase.com/blog/postgresql-views)
- [Realtime Postgres RLS on Supabase](https://supabase.com/blog/realtime-row-level-security-in-postgresql)
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { PageState, ConfDataContext, UserData } from './hooks/use-conf-data'
import Ticket from './ticket'
import ConfContainer from './conf-container'
import Form from './form'
import { SupabaseClient, Session } from '@supabase/supabase-js'
type Props = {
supabase: SupabaseClient
session: Session | null
defaultUserData: UserData
sharePage?: boolean
defaultPageState?: PageState
}
export default function Conf({
supabase,
session,
defaultUserData,
sharePage,
defaultPageState = 'registration',
}: Props) {
const [userData, setUserData] = useState<UserData>(defaultUserData)
const [pageState, setPageState] = useState<PageState>(defaultPageState)
return (
<ConfDataContext.Provider
value={{
supabase,
session,
userData,
setUserData,
setPageState,
}}
>
<ConfContainer>
{pageState === 'registration' && !sharePage ? (
<>
{/* <Hero /> */}
<Form />
{/* <LearnMore /> */}
</>
) : (
<Ticket
username={userData.username}
name={userData.name}
ticketNumber={userData.ticketNumber}
sharePage={sharePage}
golden={userData.golden}
/>
)}
</ConfContainer>
</ConfDataContext.Provider>
)
}
@@ -0,0 +1,11 @@
.container {
margin: auto 0;
padding: 0 var(--space-4x);
}
@media (min-width: 768px) {
.container {
margin: auto;
padding: 0 var(--space-8x);
}
}
@@ -0,0 +1,5 @@
import styles from './conf-container.module.css'
export default function ConfContainer({ children }: { children: React.ReactNode }) {
return <div className={styles.container}>{children}</div>
}
@@ -0,0 +1,246 @@
.form {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 12px;
gap: 12px;
}
.formLeft {
align-items: center;
}
.formCenter {
align-items: center;
}
@media (min-width: 1200px) {
.formLeft {
align-items: start;
}
.formCenter {
align-items: center;
}
}
.formInfo {
max-width: 520px;
}
.formInfo h3 {
font-size: 1.25em;
font-weight: 400;
}
.formInfo p {
font-size: 1.1em;
line-height: 1.4;
margin-bottom: 32px;
color: var(--lw-secondary-color);
}
@media (min-width: 1200px) {
/* .form.share-page {
justify-content: flex-start;
} */
}
.formInfoLeft {
text-align: center;
margin: 0 auto;
}
.formInfoCenter {
text-align: center;
margin: 0 auto;
}
@media (min-width: 1200px) {
.formInfoLeft {
text-align: left;
margin: inherit;
}
.formInfoCenter {
text-align: center;
margin: 0 auto;
}
}
.input {
border: none;
width: 100%;
background: transparent;
outline: none;
height: 56px;
padding-left: 15px;
padding-right: 15px;
font-size: var(--text-md);
color: #fff;
font-family: inherit;
}
@media (min-width: 768px) {
.input {
width: calc(100% - var(--space-32x));
padding-right: 0;
}
}
.input::placeholder {
color: var(--lw-secondary-color);
}
.input-label {
background-color: var(--gray);
border-radius: var(--space-2x);
border: 1px solid transparent;
transition: border-color 0.2s ease, background-color 0.2s ease;
display: block;
}
.input-label.focused {
background-color: #33373c;
}
.input-label.error {
background: red;
}
.input-label.success {
background: #0070f3;
}
.input-text {
display: flex;
align-items: center;
width: 100%;
}
.form-row {
position: relative;
max-width: 400px;
width: 100%;
}
@media (min-width: 768px) {
.form-row {
max-width: 480px;
}
}
.submit {
width: 100%;
height: 56px;
margin-top: var(--space-4x);
border-radius: var(--space-2x);
border: 1px solid var(--brand);
background: var(--brand);
cursor: pointer;
font-family: inherit;
font-size: var(--text-md);
letter-spacing: -0.02em;
outline: none;
font-weight: 500;
color: white;
transition: background-color 0.2s ease, color 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.submit.generate-with-github {
display: flex;
margin-bottom: 12px;
position: relative;
}
@media (min-width: 768px) {
.submit.register {
width: 120px;
height: 40px;
margin-top: 0;
position: absolute;
border-radius: 5px;
right: var(--space-2x);
top: var(--space-2x);
}
}
.submit.loading {
cursor: default;
}
.submit.default:hover,
.submit.default:focus {
background: black;
color: var(--brand);
}
.submit.default.generate-with-github:hover path,
.submit.default.generate-with-github:focus path {
fill: var(--brand);
}
.submit.error:hover,
.submit.error:focus {
background: #000;
color: #fff;
}
.submit.default:disabled,
.submit.default:disabled:hover,
.submit.default:disabled:focus {
cursor: default;
background: var(--gray);
border-color: var(--gray);
color: #fff;
justify-content: flex-start;
overflow: hidden;
}
.submit.default.generate-with-github:disabled path,
.submit.default.generate-with-github:disabled:hover path,
.submit.default.generate-with-github:disabled:focus path {
fill: #fff;
}
.submit.default.generate-with-github.not-allowed:disabled {
cursor: not-allowed;
}
@media (min-width: 1200px) {
.form-row {
margin: 0;
}
.submit.generate-with-github {
width: 240px;
}
}
.stage-btn {
background: #323332;
border: 2px solid #34b27b;
}
.github-wrapper {
display: flex;
align-items: center;
flex-direction: column;
}
.or-divider {
width: 100%;
text-align: center;
color: var(--lw-secondary-color);
margin: var(--space-4x) 0;
}
@media (min-width: 1200px) {
.github-wrapper {
flex-direction: row;
}
.or-divider {
width: 240px;
margin: 0;
}
}
@@ -0,0 +1,233 @@
import { useState, useCallback, useEffect } from 'react'
import cn from 'classnames'
import useConfData from '~/components/LaunchWeek/Ticket/hooks/use-conf-data'
import { useRouter } from 'next/router'
import LoadingDots from './loading-dots'
import styleUtils from './utils.module.css'
import styles from './form.module.css'
import useEmailQueryParam from '~/components/LaunchWeek/Ticket/hooks/use-email-query-param'
// import Captcha, { useCaptcha } from './captcha'
type FormState = 'default' | 'loading' | 'error'
export type ConfUser = {
id?: string
email?: string
ticketNumber?: number
name?: string
username?: string
createdAt?: number
golden?: boolean
}
type Props = {
sharePage?: boolean
align?: 'Left' | 'Center'
}
export default function Form({ sharePage, align = 'Center' }: Props) {
const [email, setEmail] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [errorTryAgain, setErrorTryAgain] = useState(false)
const [focused, setFocused] = useState(false)
const [formState, setFormState] = useState<FormState>('default')
const { setPageState, setUserData, session, userData, supabase } = useConfData()
const router = useRouter()
const isCaptchaEnabled = false
useEffect(() => {
if (session?.user) {
document.body.classList.add('ticket-generated')
const username = session.user.user_metadata.user_name
const name = session.user.user_metadata.full_name
const email = session.user.email
supabase
.from('lw6_tickets')
.upsert({ email, name, username }, { onConflict: 'email', ignoreDuplicates: false })
.eq('email', email)
.select()
.single()
.then(async ({ error }) => {
if (error) return supabase.auth.signOut()
const { data } = await supabase
.from('lw6_tickets_golden')
.select('*')
.eq('username', username)
.single()
setUserData(data)
setFormState('default')
// Prefetch GitHub avatar
new Image().src = `https://github.com/${username}.png`
// Prefetch the twitter share URL to eagerly generate the page
fetch(`/launch-week/tickets/${username}`).catch((_) => {})
setPageState('ticket')
})
}
}, [session])
async function register(email: string, token?: string): Promise<ConfUser> {
const { error } = await supabase!.from('lw6_tickets').insert({ email })
if (error) {
console.log({ error })
return {
id: 'new',
ticketNumber: 1234,
name: '',
username: '',
golden: false,
}
}
const { data } = await supabase!.from('lw6_tickets_golden').select('*').limit(1).single()
return {
id: data?.id ?? 'new',
ticketNumber: data?.ticketNumber ?? 1234,
name: data?.name ?? '',
username: data?.username ?? '',
golden: data?.golden ?? false,
}
}
const handleRegister = useCallback(
(token?: string) => {
register(email, token)
.then(async (params) => {
if (!params) {
throw new Error()
}
if (sharePage) {
const queryString = Object.keys(params)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof typeof params] || ''
)}`
)
.join('&')
await router.replace(`/launch-week/tickets?${queryString}`, '/launch-week/tickets')
} else {
setUserData(params)
setPageState('ticket')
}
})
.catch(async (err) => {
let message = 'Error! Please try again.'
setErrorMsg(message)
setFormState('error')
})
},
[email, router, setPageState, setUserData, sharePage]
)
const onSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (formState === 'default') {
setFormState('loading')
if (isCaptchaEnabled) {
// return executeCaptcha()
}
return handleRegister()
} else {
setFormState('default')
}
},
[formState, isCaptchaEnabled, handleRegister]
)
const onTryAgainClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setFormState('default')
setErrorTryAgain(true)
// resetCaptcha()
}, [])
useEmailQueryParam('email', setEmail)
return (
<>
<div
className={cn(
styleUtils.appear,
styleUtils['appear-fifth'],
[styles.formInfo, styles[`formInfo${align}`]].join(' ')
)}
>
<h3>Get a ticket</h3>
<p>
A few of the lucky attendees will get a limited edition Supabase goodie bag. Make sure you
don’t skip your chance.
</p>
</div>
{formState === 'error' ? (
<div
className={cn(styles.form, {
[styles['share-page']]: sharePage,
})}
>
<div className={styles['form-row']}>
<div className={cn(styles['input-label'], styles.error)}>
<div className={cn(styles.input, styles['input-text'])}>{errorMsg}</div>
<button
type="button"
className={cn(styles.submit, styles.register, styles.error)}
onClick={onTryAgainClick}
>
Try Again
</button>
</div>
</div>
</div>
) : (
<form
className={cn(styles.form, styles[`form${align}`], {
[styles['share-page']]: sharePage,
[styleUtils.appear]: !errorTryAgain,
[styleUtils['appear-fifth']]: !errorTryAgain && !sharePage,
[styleUtils['appear-third']]: !errorTryAgain && sharePage,
})}
onSubmit={onSubmit}
>
<div className={styles['form-row']}>
<label
htmlFor="email-input-field"
className={cn(styles['input-label'], {
[styles.focused]: focused,
})}
>
<input
className={styles.input}
autoComplete="off"
type="email"
id="email-input-field"
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Enter email to register free"
aria-label="Your email address"
required
/>
</label>
<button
type="submit"
className={cn(styles.submit, styles.register, styles[formState])}
disabled={formState === 'loading'}
>
{formState === 'loading' ? <LoadingDots size={4} /> : <>Register</>}
</button>
</div>
{/* <Captcha ref={captchaRef} onVerify={handleRegister} /> */}
</form>
)}
</>
)
}
@@ -0,0 +1,30 @@
import { Session, SupabaseClient } from '@supabase/supabase-js'
import { createContext, useContext } from 'react'
export type PageState = 'registration' | 'ticket'
export type UserData = {
id?: string
ticketNumber?: number
username?: string
name?: string
golden?: boolean
}
type ConfDataContextType = {
supabase: SupabaseClient
session: Session | null
userData: UserData
setUserData: React.Dispatch<React.SetStateAction<UserData>>
setPageState: React.Dispatch<React.SetStateAction<PageState>>
}
export const ConfDataContext = createContext<ConfDataContextType | null>(null)
export default function useConfData() {
const result = useContext(ConfDataContext)
if (!result) {
throw new Error()
}
return result
}
@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useRouter } from 'next/router'
/**
* If `paramName` exists in query string, then call `setEmail()` with the value
* and delete it from the URL.
*/
export default function useEmailQueryParam(
paramName: string,
setEmail: (email: string) => unknown
) {
const router = useRouter()
useEffect(() => {
if ('URLSearchParams' in window) {
const { search, pathname } = window.location
const params = new URLSearchParams(search)
const email = params.get(paramName)
if (email) {
setEmail(email)
params.delete(paramName)
const newSearch = params.toString()
const newAsPath = pathname + (newSearch ? `?${newSearch}` : '')
const newPathname = router.pathname + (newSearch ? `?${newSearch}` : '')
history.replaceState(
{ url: newPathname, as: newAsPath, options: { shallow: true } },
'',
newAsPath
)
}
}
}, [setEmail, router.pathname, paramName])
}
@@ -0,0 +1,48 @@
.loading {
display: inline-flex;
align-items: center;
--loading-dots-height: auto;
--loading-dots-size: 2px;
height: var(--loading-dots-height);
}
.loading .spacer {
margin-right: var(--space-3x);
}
.loading span {
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: var(--loading-dots-size);
height: var(--loading-dots-size);
border-radius: 50%;
background-color: var(--accents-6);
display: inline-block;
margin: 0 1px;
}
.loading.reverse span {
background-color: var(--accents-2);
}
.loading span:nth-of-type(2) {
animation-delay: 0.2s;
}
.loading span:nth-of-type(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@@ -0,0 +1,27 @@
import { toPixels as px } from '~/lib/helpers'
import cn from 'classnames'
import styles from './loading-dots.module.css'
interface Props {
size?: number
height?: number | string
reverse?: boolean
children?: React.ReactNode
}
export default function LoadingDots({ size = 2, height, children, reverse }: Props) {
return (
<span
className={cn(styles.loading, { [styles.reverse]: reverse })}
style={{
['--loading-dots-height' as string]: height ? px(height) : undefined,
['--loading-dots-size' as string]: size !== 2 ? px(size) : undefined,
}}
>
{children && <div className={styles.spacer}>{children}</div>}
<span />
<span />
<span />
</span>
)
}
@@ -0,0 +1,70 @@
.button {
border-radius: var(--space-2x);
border: 1px solid #fff;
color: #000;
background: #fff;
cursor: pointer;
font-family: inherit;
font-size: var(--text-md);
letter-spacing: -0.02em;
font-weight: 500;
outline: none;
transition: background-color 0.2s ease;
display: grid;
grid-template-columns: 24px 1fr;
text-align: center;
align-items: center;
padding: 0 var(--space-4x);
height: 55px;
width: 100%;
max-width: 400px;
margin: var(--space-2x) auto;
}
.loading {
grid-template-columns: 1fr;
justify-items: center;
}
@media (min-width: 768px) {
.button {
width: 200px;
margin: 0 6px;
}
}
.button:hover,
.button:focus {
background: black;
color: #fff;
}
.first {
animation-delay: 2s;
}
.second {
animation-delay: 2.2s;
}
.third {
animation-delay: 2.4s;
}
:global(.ticket-generated) .first {
animation-delay: 0.6s;
}
:global(.ticket-generated) .second {
animation-delay: 0.8s;
}
:global(.ticket-generated) .third {
animation-delay: 1s;
}
.linkedin-button {
display: none !important;
}
@media (min-width: 768px) {
.linkedin-button {
display: grid !important;
}
}
@@ -0,0 +1,90 @@
import { useState, useRef, useEffect } from 'react'
import cn from 'classnames'
import { SITE_URL, TWEET_TEXT, TWEET_TEXT_GOLDEN } from '~/lib/constants'
// import IconTwitter from '~/components/LaunchWeek/Ticket/icons/icon-twitter'
// import IconLinkedin from '~/components/LaunchWeek/Ticket/icons/icon-linkedin'
// import IconDownload from '~/components/LaunchWeek/Ticket/icons/icon-download'
import LoadingDots from './loading-dots'
import styleUtils from './utils.module.css'
import styles from './ticket-actions.module.css'
import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data'
type Props = {
username: string
golden?: boolean
}
export default function TicketActions({ username, golden = false }: Props) {
const [imgReady, setImgReady] = useState(false)
const [loading, setLoading] = useState(false)
const downloadLink = useRef<HTMLAnchorElement>()
const permalink = encodeURIComponent(`${SITE_URL}/tickets/${username}`)
const text = encodeURIComponent(golden ? TWEET_TEXT_GOLDEN : TWEET_TEXT)
const { userData } = useConfData()
const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink}&via=supabase&text=${text}`
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink}`
const downloadUrl = `https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent(
userData.username ?? ''
)}`
useEffect(() => {
setImgReady(false)
const img = new Image()
img.src = downloadUrl
img.onload = () => {
setImgReady(true)
setLoading(false)
if (downloadLink.current) {
downloadLink.current.click()
downloadLink.current = undefined
}
}
}, [downloadUrl])
return (
<>
<a
className={cn(styles.button, styleUtils.appear, styles.first, 'icon-button')}
href={tweetUrl}
rel="noopener noreferrer"
target="_blank"
>
{/* <IconTwitter width={24} /> Tweet it! */}
</a>
<a
className={cn(
styles.button,
styleUtils.appear,
styles.second,
'icon-button',
// LinkedIn Share widget doesn’t work on mobile
styles['linkedin-button']
)}
href={linkedInUrl}
rel="noopener noreferrer"
target="_blank"
>
{/* <IconLinkedin width={20} /> Share on LinkedIn */}
</a>
<a
className={cn(styles.button, styleUtils.appear, styles.third, 'icon-button', {
[styles.loading]: loading,
})}
href={loading ? undefined : downloadUrl}
onClick={(e) => {
if (imgReady) return
e.preventDefault()
downloadLink.current = e.currentTarget
// Wait for the image download to finish
setLoading(true)
}}
download="ticket.png"
>
{loading ? <LoadingDots size={4} /> : <>{/* <IconDownload width={24} /> Download */}</>}
</a>
</>
)
}
@@ -0,0 +1,190 @@
.wrapper {
color: var(--lw-secondary-color);
display: flex;
align-items: center;
justify-content: center;
animation-delay: 2.6s;
flex-direction: column;
}
:global(.ticket-generated) .wrapper {
animation-delay: 1.2s;
}
.label {
width: 100%;
margin-bottom: var(--space-2x);
}
.label-wrapper {
display: flex;
align-items: center;
max-width: 400px;
width: 100%;
justify-content: space-between;
}
@media (min-width: 768px) {
.wrapper {
flex-direction: row;
}
.label {
width: auto;
margin-right: 10px;
margin-bottom: 0px;
}
.label-wrapper {
width: auto;
}
}
.field {
position: relative;
border-radius: var(--space-2x);
overflow: hidden;
background: var(--gray);
}
@media (min-width: 768px) {
.field {
padding-right: 50px;
}
.field.desktop-copy-disabled {
padding-right: 0px;
}
}
.mobile-copy {
margin-bottom: 10px;
display: flex;
}
.mobile-copy-disabled.mobile-copy-disabled {
display: none;
}
.mobile-copy * {
color: var(--lw-secondary-color);
transition: color 0.2s ease;
}
.mobile-copy:hover * {
color: #fff;
transition: color 0.2s ease;
}
.field * {
color: var(--lw-secondary-color);
transition: color 0.2s ease;
}
.field:hover * {
color: #fff;
transition: color 0.2s ease;
}
.url {
width: calc(100vw - 36px);
user-select: all;
overflow-x: scroll;
white-space: nowrap;
display: block;
padding: var(--space-2x) 14px;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@media (min-width: 436px) {
.url {
width: 400px;
}
}
@media (min-width: 768px) {
.url {
width: 400px;
}
.mobile-copy {
margin-bottom: 0;
display: none;
}
}
.url::-webkit-scrollbar {
display: none;
}
.fade {
position: absolute;
background: linear-gradient(90deg, rgba(37, 39, 41, 0%) 0%, #252729 100%);
top: 0;
bottom: 0;
right: 0px;
width: 50px;
z-index: 1;
display: block;
border-radius: var(--space-2x);
pointer-events: none;
}
.desktop-copy.desktop-copy-disabled {
display: none;
}
@media (min-width: 768px) {
.fade {
right: 50px;
}
.fade.desktop-copy-disabled {
right: 0px;
}
}
.copied {
display: flex;
z-index: 2;
align-items: center;
justify-content: flex-end;
opacity: 0;
transition: opacity 0.2s ease !important;
}
.copied.visible {
opacity: 1;
}
.copy-button {
background: none;
outline: none;
border: none;
cursor: pointer;
z-index: 2;
border-radius: var(--space-2x);
width: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-right: -10px;
}
@media (min-width: 768px) {
.copied {
position: absolute;
right: 50px;
top: 0px;
bottom: 0px;
background: linear-gradient(90deg, rgba(37, 39, 41, 0%) 0px, #252729 40px);
padding-left: 50px;
}
.copy-button {
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
margin-right: 0px;
width: 50px;
}
}
@@ -0,0 +1,109 @@
import { useEffect, useState, useRef } from 'react'
import cn from 'classnames'
import { SITE_URL } from '~/lib/constants'
import styleUtils from './utils.module.css'
// import IconCopy from '~/components/LaunchWeek/Ticket/icons/icon-copy'
import styles from './ticket-copy.module.css'
type Props = {
username: string
}
export default function TicketCopy({ username }: Props) {
const [fadeOpacity, setFadeOpacity] = useState(1)
const [scrolling, setScrolling] = useState(false)
const [copyEnabled, setCopyEnabled] = useState(false)
const [copied, setCopied] = useState(false)
const scrollRef = useRef<HTMLSpanElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const url = `${SITE_URL}/tickets/${username}`
useEffect(() => {
if (navigator.clipboard) {
setCopyEnabled(true)
}
}, [])
const copiedText = (
<span
className={cn(styles.copied, {
[styles.visible]: copied,
})}
>
Copied!
</span>
)
const copyButton = (
<button
type="button"
className={styles['copy-button']}
ref={buttonRef}
onClick={() => {
navigator.clipboard.writeText(url).then(() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 2000)
})
}}
>
{/* <IconCopy /> */}
</button>
)
return (
<div className={cn(styles.wrapper, styleUtils.appear)}>
<div className={styles['label-wrapper']}>
<div className={styles.label}>Your ticket URL:</div>
<div
className={cn(styles['mobile-copy'], {
[styles['mobile-copy-disabled']]: !copyEnabled,
})}
>
{copiedText}
{copyButton}
</div>
</div>
<div
className={cn(styles.field, {
[styles['desktop-copy-disabled']]: !copyEnabled,
})}
>
<span
className={styles.url}
ref={scrollRef}
onScroll={() => {
if (!scrolling) {
setScrolling(true)
const animationFrame = requestAnimationFrame(() => {
const scrollableWidth =
(scrollRef.current?.scrollWidth || 0) - (scrollRef.current?.clientWidth || 0)
setFadeOpacity(
(scrollableWidth - (scrollRef.current?.scrollLeft || 0)) / (scrollableWidth || 1)
)
cancelAnimationFrame(animationFrame)
setScrolling(false)
})
}
}}
>
{url}
</span>
<span
className={cn(styles.fade, {
[styles['desktop-copy-disabled']]: !copyEnabled,
})}
style={{ opacity: fadeOpacity }}
/>
<div
className={cn(styles['desktop-copy'], styleUtils['hide-on-mobile'], {
[styles['desktop-copy-disabled']]: !copyEnabled,
})}
>
{copiedText}
{copyButton}
</div>
</div>
</div>
)
}
@@ -0,0 +1,65 @@
.githubIcon {
margin-left: 12px;
margin-right: var(--space-4x);
display: inline-flex;
}
.checkIcon {
position: absolute;
right: var(--space-4x);
display: inline-flex;
}
.generateWithGithub {
display: flex;
align-items: center;
font-weight: 500;
}
.stageIcon {
position: absolute;
left: var(--space-4x);
display: inline-flex;
}
.form-row {
margin-left: auto;
margin-right: auto;
max-width: 600px;
}
.description {
color: var(--secondary-color);
margin: 0;
text-align: center;
margin-left: var(--space-4x);
}
@media (min-width: 768px) {
.form-row {
margin-bottom: 20px;
}
}
@media (min-width: 1200px) {
.form-row {
margin: 0;
}
.description {
text-align: left;
}
.generateWithGithub {
width: 100%;
}
}
.learn-more {
color: #fff;
}
.learn-more:hover {
text-decoration: underline;
color: #fff;
}
@@ -0,0 +1,103 @@
import { useState, useRef } from 'react'
// import { scrollTo } from '@lib/smooth-scroll';
import cn from 'classnames'
// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github'
// import CheckIcon from '~/components/LaunchWeek/Ticket/icons/icon-check'
import { SITE_ORIGIN } from '~/lib/constants'
import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data'
import LoadingDots from './loading-dots'
import formStyles from './form.module.css'
import ticketFormStyles from './ticket-form.module.css'
type FormState = 'default' | 'loading' | 'error'
type TicketGenerationState = 'default' | 'loading'
type Props = {
defaultUsername?: string
setTicketGenerationState: React.Dispatch<React.SetStateAction<TicketGenerationState>>
}
export default function Form({ defaultUsername = '', setTicketGenerationState }: Props) {
const [username] = useState(defaultUsername)
const [formState, setFormState] = useState<FormState>('default')
const [errorMsg] = useState('')
const { supabase } = useConfData()
const formRef = useRef<HTMLFormElement>(null)
return formState === 'error' ? (
<div>
<div className={cn(formStyles['form-row'], ticketFormStyles['form-row'])}>
<div className={cn(formStyles['input-label'], formStyles.error)}>
<div className={cn(formStyles.input, formStyles['input-text'])}>{errorMsg}</div>
<button
type="button"
className={cn(formStyles.submit, formStyles.error)}
onClick={() => {
setFormState('default')
setTicketGenerationState('default')
}}
>
Try Again
</button>
</div>
</div>
</div>
) : (
<form
ref={formRef}
onSubmit={async (e) => {
e.preventDefault()
if (formState !== 'default') {
setTicketGenerationState('default')
setFormState('default')
return
}
setFormState('loading')
setTicketGenerationState('loading')
await supabase.auth.signInWithOAuth({
provider: 'github',
options: { redirectTo: `${SITE_ORIGIN}/launch-week/tickets` },
})
}}
>
<div className={cn(formStyles['form-row'], ticketFormStyles['form-row'])}>
<div className={cn(formStyles['github-wrapper'])}>
<button
type="submit"
className={cn(
formStyles.submit,
formStyles['generate-with-github'],
formStyles[formState]
)}
disabled={formState === 'loading' || Boolean(username)}
onClick={() => {
// if (formRef && formRef.current && isMobileOrTablet()) {
// scrollTo(formRef.current, formRef.current.offsetHeight);
// }
}}
>
<div className={ticketFormStyles.generateWithGithub}>
<span className={ticketFormStyles.githubIcon}>
{/* <GithubIcon color="#fff" size={24} /> */}
</span>
{formState === 'loading' ? (
<LoadingDots size={4} />
) : (
username || 'Generate with GitHub'
)}
</div>
{username ? (
<span className={ticketFormStyles.checkIcon}>
{/* <CheckIcon color="#fff" size={24} /> */}
</span>
) : null}
</button>
<p className={ticketFormStyles.description}>Only public info will be used.</p>
</div>
</div>
</form>
)
}
@@ -0,0 +1,17 @@
.background {
width: 2000px;
height: 1000px;
background: #000;
color: #fff;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.page {
width: 1700px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Noto Sans', 'Noto Sans JP',
'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', sans-serif;
}
@@ -0,0 +1,37 @@
import { useRouter } from 'next/router'
import Head from 'next/head'
import TicketVisual from './ticket-visual'
import styles from './ticket-image.module.css'
export default function TicketImage() {
const { query } = useRouter()
if (query.ticketNumber) {
return (
<div className={styles.background}>
<div className={styles.page}>
<Head>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&Noto+Sans+HK:wght@700&family=Noto+Sans+JP:wght@700&family=Noto+Sans+KR:wght@700&family=Noto+Sans+SC:wght@700&family=Noto+Sans+TC:wght@700&family=Noto+Sans:wght@700&display=swap"
rel="stylesheet"
/>
</Head>
<TicketVisual
size={1700 / 650}
username={query.username ? query.username.toString() : undefined}
ticketNumber={parseInt(query.ticketNumber.toString(), 10)}
name={
query.name
? query.name?.toString()
: query.username
? query.username.toString()
: undefined
}
golden={query.golden ? true : false}
/>
</div>
</div>
)
}
return <></>
}
@@ -0,0 +1,93 @@
.info {
display: flex;
flex-direction: column;
gap: 0px;
margin-bottom: 64px;
padding: 32px;
}
@media (min-width: 768px) {
.info {
/* display: grid;
grid-template-columns: 1fr 2fr;
gap: calc(var(--space-4x) * var(--size));
font-size: calc(1em * var(--size)); */
gap: calc(8px * var(--size));
padding: 0;
margin-bottom: calc(32px * var(--size));
margin-left: calc(160px * var(--size));
}
}
.logo {
/* margin-bottom: var(--space-6x); */
/* max-width: 96px; */
font-size: 18px;
}
/* @media (min-width: 768px) {
.logo {
margin-bottom: 0;
font-size: calc(16px * var(--size));
}
} */
.date {
/* text-transform: uppercase; */
font-size: calc(18px * var(--size));
color: var(--lw-secondary-color);
}
.date-golden {
color: var(--gold-primary);
}
@media (min-width: 768px) {
.date {
margin-bottom: 0;
line-height: 1.15;
font-size: calc(20px * var(--size));
}
}
.created-by {
display: flex;
align-items: center;
color: var(--accents-4);
}
@media (min-width: 768px) {
.created-by {
margin-right: var(--space-4x);
}
}
.created-by-text {
white-space: nowrap;
margin-right: var(--space);
}
/* .created-by-logo {
height: calc(16px * var(--size));
display: inline-flex;
} */
.url {
margin-bottom: var(--space-12x);
font-size: calc(18px * var(--size));
color: var(--lw-secondary-color);
}
.url-golden {
color: var(--gold-primary);
}
@media (min-width: 768px) {
.url {
margin-bottom: 0;
font-size: calc(20px * var(--size));
line-height: 1.15;
}
}
@@ -0,0 +1,20 @@
import styles from './ticket-info.module.css'
import cn from 'classnames'
import { DATE, SITE_URL } from '~/lib/constants'
const siteUrl = new URL(SITE_URL)
const siteUrlForTicket = `${siteUrl.host}${siteUrl.pathname}`.replace(/\/$/, '')
export default function TicketInfo({
logoTextSecondaryColor = 'var(--accents-5)',
golden = false,
}) {
return (
<div className={styles.info}>
<div className={cn(styles.date, { [styles['date-golden']]: golden })}>
<div>{DATE}</div>
</div>
<div className={cn(styles.url, { [styles['url-golden']]: golden })}>{siteUrlForTicket}</div>
</div>
)
}
@@ -0,0 +1,34 @@
// NOTE: When you export SVG from Figma, you must:
// - Update width and height as 100%
// - Change id to "mobile-..."
// - Change url(#) to url(#mobile-)
// This is because if a page has two same IDs it will fail.
export default function TicketMonoMobile({ golden = false }: { golden?: boolean }) {
const frameColor = golden ? '#F2C94C' : '#3fcf8e'
const perforationColor = golden ? 'var(--gold-accent)' : '#252729'
return (
<svg
width="100%"
height="100%"
viewBox="0 0 330 560"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* <path
fillRule="evenodd"
clipRule="evenodd"
d="M2.9193e-06 540C3.40212e-06 551.046 8.95431 560 20 560L138 560C138 545.088 150.088 533 165 533C179.912 533 192 545.088 192 560L310 560C321.046 560 330 551.046 330 540L330 20C330 8.95427 321.046 -1.40334e-05 310 -1.35505e-05L192 -8.39259e-06C192 14.9117 179.912 27 165 27C150.088 27 138 14.9117 138 -6.03217e-06L20 -8.74228e-07C8.95428 -3.91405e-07 -2.41646e-05 8.95428 -2.36041e-05 20L2.9193e-06 540Z"
fill={frameColor}
/> */}
{/* <path
fillRule="evenodd"
clipRule="evenodd"
d="M5 539C5 547.837 12.1634 555 21 555L133.388 555C135.789 539.702 149.028 528 165 528C180.972 528 194.211 539.702 196.612 555L309 555C317.837 555 325 547.837 325 539L325 21C325 12.1634 317.837 4.99999 309 4.99999L196.612 4.99999C194.211 20.2981 180.972 32 165 32C149.028 32 135.789 20.2982 133.388 4.99999L21 5C12.1634 5 4.99998 12.1635 4.99998 21L5 539Z"
fill="black"
/> */}
<path d="M326 446H5" stroke={perforationColor} strokeDasharray="6 6" />
</svg>
)
}
@@ -0,0 +1,23 @@
export default function TicketMono({ golden = false }: { golden?: boolean }) {
const frameColor = golden ? '#F2C94C' : '#252729'
const perforationColor = golden ? 'var(--gold-accent)' : '#252729'
return (
<svg
width="100%"
height="100%"
viewBox="0 0 650 330"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21 5C12.1634 5 5 12.1634 5 21V133.388C20.2981 135.789 32 149.028 32 165C32 180.972 20.2981 194.211 5 196.612V309C5 317.837 12.1634 325 21 325H629C637.837 325 645 317.837 645 309V196.612C629.702 194.211 618 180.972 618 165C618 149.028 629.702 135.789 645 133.388V21C645 12.1634 637.837 5 629 5H21Z"
// fill="black"
/>
<path d="M512 5V326" stroke={perforationColor} strokeDasharray="4 4" />
</svg>
)
}
@@ -0,0 +1,14 @@
type Props = {
number: number
}
export default function TicketNumber({ number }: Props) {
const numDigits = `${number}`.length
const prefix = `000000`.slice(numDigits)
return (
<>
№ {prefix}
{number}
</>
)
}
@@ -0,0 +1,155 @@
.profile {
display: flex;
align-items: center;
}
.image {
width: 60px;
height: 60px;
border-radius: 50%;
border: 1px solid #333;
}
@media (min-width: 768px) {
.image {
width: calc(80px * var(--size));
height: calc(80px * var(--size));
}
}
.text {
flex: 1;
margin-left: 10px;
}
@media (min-width: 768px) {
.text {
margin-left: calc(15px * var(--size));
}
}
.name {
font-size: 24px;
display: inline-block;
line-height: 1.15;
letter-spacing: -0.02em;
margin: 0;
}
.name-golden {
color: var(--gold-primary);
}
@media (min-width: 768px) {
.name {
font-size: calc(var(--space-8x) * var(--size));
margin-bottom: 5px;
}
}
.username {
display: inline-block;
font-size: calc(1em * var(--size));
color: var(--lw-secondary-color);
display: flex;
align-items: center;
margin: 0;
}
.username-golden {
color: var(--gold-secondary);
}
.githubIcon {
margin-right: calc(5px * var(--size));
display: inline-flex;
}
.githubIcon-golden svg {
color: var(--gold-secondary);
opacity: 0.5;
}
.empty-icon {
display: block;
background: linear-gradient(135deg, #b4b6b8, #7f8183);
width: 60px;
}
@media (min-width: 768px) {
.empty-icon {
width: calc(80px * var(--size));
}
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton {
display: flex;
align-items: center;
border-radius: 5px;
}
.skeleton.loaded {
width: unset !important;
}
.skeleton:not(.wrapper):not(.show) {
display: none;
}
.wrapper:not(.show)::before {
content: none;
}
.skeleton:not(.wrapper):not(.loaded) {
border-radius: var(--space);
background-image: linear-gradient(270deg, #252729, #34383d, #34383d, #252729);
background-size: 200% 100%;
animation: loading 2s ease-in-out infinite;
}
.wrapper {
position: relative;
}
.wrapper::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: 0;
bottom: 0;
border-radius: var(--space);
z-index: 100;
background-image: linear-gradient(270deg, #111111, #333333, #333333, #111111);
background-size: 200% 100%;
animation: loading 2s ease-in-out infinite;
}
.inline {
display: inline-block !important;
}
.rounded,
.rounded.wrapper::before {
width: 62px;
height: 62px;
border-radius: 50% !important;
}
@media (min-width: 768px) {
.rounded,
.rounded.wrapper::before {
width: calc(82px * var(--size));
height: calc(82px * var(--size));
border-radius: 50% !important;
}
}
@@ -0,0 +1,69 @@
// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github'
import cn from 'classnames'
// import IconAvatar from '~/components/LaunchWeek/Ticket/icons/icon-avatar'
import styles from './ticket-profile.module.css'
type TicketGenerationState = 'default' | 'loading'
type Props = {
name?: string
username?: string
size?: number
ticketGenerationState: TicketGenerationState
golden?: boolean
}
export default function TicketProfile({
name,
username,
size = 1,
ticketGenerationState,
golden = false,
}: Props) {
return (
<div className={styles.profile}>
<span
className={cn(styles.skeleton, styles.wrapper, styles.inline, styles.rounded, {
[styles.show]: ticketGenerationState === 'loading',
})}
>
{username ? (
<img src={`https://github.com/${username}.png`} alt={username} className={styles.image} />
) : (
<span className={cn(styles.image, styles['empty-icon'])}>{/* <IconAvatar /> */}</span>
)}
</span>
<div className={styles.text}>
<p
className={cn(
styles.name,
{ [styles['name-blank']]: !username },
{ [styles['name-golden']]: golden }
)}
>
<span
className={cn(styles.skeleton, styles.wrapper, {
[styles.show]: ticketGenerationState === 'loading',
})}
>
{name || username || 'Your Name'}
</span>
</p>
<p className={cn(styles.username, { [styles['username-golden']]: golden })}>
<span
className={cn(styles.skeleton, styles.wrapper, {
[styles.show]: ticketGenerationState === 'loading',
})}
>
<span className={cn(styles.githubIcon, { [styles['githubIcon-golden']]: golden })}>
{/* <GithubIcon
color={golden ? 'var(--gold-primary)' : 'var(--secondary-color)'}
size={20 * size}
/> */}
</span>
{username || <>username</>}
</span>
</p>
</div>
</div>
)
}
@@ -0,0 +1,173 @@
.visual {
position: relative;
transform: translateZ(0);
display: flex;
flex-direction: column;
justify-content: space-between;
/* background-color: black; */
border: 2px solid rgba(0, 0, 0, 0.1);
border-radius: 16px;
background-color: rgba(0, 0, 0, 1);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
/* background: linear-gradient(#fff000, var(--gold-secondary)); */
border: 1px solid rgba(255, 255, 0, 0.629);
animation: blank-svg-shadow 2s ease-in-out infinite alternate;
}
.visual--gold {
background-color: gold;
/* border: 10px solid;
border-image-slice: 1;
border-width: 2px;
border-radius: 16px; */
background: linear-gradient(#fff000, var(--gold-secondary));
border: 1px solid rgba(255, 255, 0, 0.629);
animation: svg-shadow 2s ease-in-out infinite alternate;
/* filter: drop-shadow(2.45223px 8.5828px 73.5669px #FFBD14); */
/* border-image-source: linear-gradient(to left, var(--gold-secondary), var(--gold-accent)); */
}
/* .svg-shadow {
} */
@keyframes svg-shadow {
from {
filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 15px var(--gold-primary))
drop-shadow(0 0 20px var(--gold-accent));
}
to {
filter: drop-shadow(0 0 20px #fff) drop-shadow(0 0 10px var(--gold-accent))
drop-shadow(0 0 40px var(--gold-primary));
}
}
@keyframes blank-svg-shadow {
from {
filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 15px #12f28d) drop-shadow(0 0 20px #12f28d);
}
to {
filter: drop-shadow(0 0 20px #fff) drop-shadow(0 0 10px #12f28d) drop-shadow(0 0 40px #12f28d);
}
}
.horizontal-ticket {
display: none;
}
.vertical-ticket {
display: flex;
}
@media (min-width: 768px) {
.horizontal-ticket {
display: flex;
}
.vertical-ticket {
display: none;
}
}
.logo {
z-index: 1;
position: absolute;
top: 0;
width: 100%;
height: 100%;
/* padding: 46px 29px;/ */
}
.logo img {
/* margin-top: 64px; */
margin-top: calc(64px * var(--size));
margin-left: calc(32px * var(--size));
margin-left: calc(32px * var(--size));
/* width: 272px; */
width: calc(272px * var(--size));
}
@media (min-width: 768px) {
.logo img {
margin-top: calc(32px * var(--size));
/* width: 280px; */
width: calc(280px * var(--size));
margin-left: calc(160px * var(--size));
}
}
.profile {
z-index: 1;
position: absolute;
top: 90px;
width: 100%;
height: 100%;
padding: 46px 29px;
}
@media (min-width: 768px) {
.profile {
top: calc(48px * var(--size));
padding: calc(44px * var(--size)) calc(160px * var(--size)) calc(44px * var(--size))
calc(63px * var(--size));
}
}
.info {
z-index: 1;
position: absolute;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* padding: calc(46px * var(--size)) calc(29px * var(--size)); */
}
@media (min-width: 768px) {
.info {
bottom: 0;
/* padding: 0 calc(160px * var(--size)) calc(44px * var(--size))
calc(63px * var(--size)); */
}
}
.ticket-number-wrapper {
position: absolute;
bottom: 40px;
right: 0;
}
@media (min-width: 768px) {
.ticket-number-wrapper {
bottom: 0;
}
}
.ticket-number {
font-weight: bold;
font-size: calc(40px * var(--size));
line-height: 1;
width: calc(330px * var(--size));
text-align: center;
}
.ticket-number-golden {
color: var(--gold-primary);
}
@media (min-width: 768px) {
.ticket-number {
transform: rotate(90deg) translateY(calc(100px * var(--size)));
transform-origin: bottom right;
}
.ticket-number-golden {
color: var(--gold-accent);
}
}
@@ -0,0 +1,83 @@
import styles from './ticket-visual.module.css'
import TicketProfile from './ticket-profile'
import TicketNumber from './ticket-number'
import TicketMono from './ticket-mono'
import TicketInfo from './ticket-info'
import TicketMonoMobile from './ticket-mono-mobile'
import cn from 'classnames'
import { useRouter } from 'next/router'
type TicketGenerationState = 'default' | 'loading'
type Props = {
size?: number
name?: string
ticketNumber?: number
username?: string
ticketGenerationState?: TicketGenerationState
golden?: boolean
}
export default function TicketVisual({
size = 1,
name,
username,
ticketNumber,
ticketGenerationState = 'default',
golden = false,
}: Props) {
// golden = true;
const router = useRouter()
const basePath = router.basePath
return (
<>
<div
className={[styles.visual, golden ? styles['visual--gold'] : ''].join(' ')}
style={{ ['--size' as string]: size }}
>
<div className={styles['horizontal-ticket']}>
{/* {username ? <TicketColored golden={golden} /> : <TicketMono golden={golden} />} */}
<TicketMono golden={golden} />
</div>
<div className={styles['vertical-ticket']}>
{/* {username ? (
<TicketColoredMobile golden={golden} />
) : (
<TicketMonoMobile golden={golden} />
)} */}
<TicketMonoMobile golden={golden} />
</div>
<div className={styles.logo}>
<img src="/images/launchweek/launchweek-logo--dark.svg" />
</div>
<div className={styles.profile}>
<TicketProfile
name={name}
username={username}
size={size}
ticketGenerationState={ticketGenerationState}
golden={golden}
/>
</div>
<div className={styles.info}>
<TicketInfo
golden={golden}
logoTextSecondaryColor={
ticketNumber ? (golden ? '#F2C94C' : 'var(--brand)') : undefined
}
/>
</div>
{ticketNumber && (
<div className={styles['ticket-number-wrapper']}>
<div
className={cn(styles['ticket-number'], { [styles['ticket-number-golden']]: golden })}
>
<TicketNumber number={ticketNumber} />
</div>
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,161 @@
.hero {
font-size: 30px;
line-height: 1.15;
letter-spacing: -0.05em;
font-weight: 400;
margin: 0;
text-align: center;
}
@media (min-width: 768px) {
.hero {
font-size: 30px;
line-height: 1;
/* font-weight: 800; */
/* max-width: 800px; */
/* margin-left: auto;
margin-right: auto; */
}
}
.description {
font-size: 20px;
line-height: 1.4;
color: var(--lw-secondary-color);
text-align: center;
margin: 30px auto;
max-width: 350px;
}
@media (min-width: 768px) {
.description {
font-size: 24px;
max-width: 415px;
}
}
.ticket-text {
margin-bottom: 30px;
}
.ticket-layout {
display: grid;
grid-template-columns: 1fr;
gap: 45px;
margin: auto;
padding: var(--space-4x);
padding-top: 64px;
padding-bottom: 64px;
}
@media (min-width: 1200px) {
.ticket-layout {
padding-top: 0;
padding-bottom: 0;
}
}
.ticket-share-layout {
align-items: center;
margin-bottom: auto;
}
.ticket-visual {
position: relative;
border-radius: calc(16px * var(--size));
/* overflow: hidden; */
width: 330px;
margin: 0 auto;
}
/* .ticket-visual:before,
.ticket-visual:after { */
/* content: '';
display: block;
width: 72px;
height: 58px; */
/* background-color: #1C1C1C; */
/* position: absolute;
z-index: 100;
border-radius: 50%; */
/* } */
.ticket-visual:before {
left: 50%;
top: -7.5%;
transform: translateZ(0) translateX(-50%) rotate(90deg);
}
.ticket-visual:after {
left: 50%;
bottom: -7.5%;
transform: translateZ(0) translateX(-50%) rotate(90deg);
}
@media (min-width: 768px) {
.ticket-visual {
width: 650px;
}
.ticket-visual:before {
top: 50%;
left: -7%;
transform: translateZ(0) translateY(-50%);
}
.ticket-visual:after {
top: 50%;
left: 96%;
transform: translateZ(0) translateY(-50%);
}
}
.ticket-actions {
margin-top: 30px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.ticket-copy {
margin-top: 30px;
}
.ticket-actions-placeholder {
height: 280px;
}
@media (min-width: 768px) {
.ticket-actions {
flex-direction: row;
}
.ticket-actions-placeholder {
height: 157px;
}
}
@media (min-width: 1200px) {
.ticket-layout {
grid-template-columns: 1fr 1fr;
}
.hero {
font-size: 48px;
text-align: left;
}
.description {
text-align: left;
margin: 30px 0;
}
}
.ticket-visual-wrapper {
border-radius: calc(16px * var(--size));
width: 100%;
}
@media screen and (min-width: 1280px) {
.ticket-layout {
padding: 0 40px;
}
}
@@ -0,0 +1,132 @@
import cn from 'classnames'
import Tilt from 'vanilla-tilt'
import { useRef, useEffect, useState } from 'react'
// import { TicketGenerationState } from '@lib/constants'
// import {isMobileOrTablet} from '~/lib/helpers'
// import { scrollTo } from '@lib/smooth-scroll'
import styles from './ticket.module.css'
import styleUtils from './utils.module.css'
import TicketForm from './ticket-form'
import TicketVisual from './ticket-visual'
import TicketActions from './ticket-actions'
import TicketCopy from './ticket-copy'
import { DATE, SITE_NAME } from '~/lib/constants'
import Form from './form'
import { UserData } from './hooks/use-conf-data'
type TicketGenerationState = 'default' | 'loading'
type Props = {
username: UserData['username']
ticketNumber: UserData['ticketNumber']
name: UserData['name']
golden: UserData['golden']
sharePage?: boolean
}
export default function Ticket({ username, name, ticketNumber, sharePage, golden }: Props) {
const ticketRef = useRef<HTMLDivElement>(null)
const [ticketGenerationState, setTicketGenerationState] =
useState<TicketGenerationState>('default')
const divRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ticketRef.current && !window.matchMedia('(pointer: coarse)').matches) {
Tilt.init(ticketRef.current, {
glare: true,
max: 5,
'max-glare': 0.16,
'full-page-listening': true,
})
}
}, [ticketRef])
// useEffect(() => {
// if (!sharePage && divRef && divRef.current && isMobileOrTablet()) {
// scrollTo(divRef.current, -30)
// }
// }, [divRef, sharePage])
return (
<div
className={cn(styles['ticket-layout'], {
[styles['ticket-share-layout']]: sharePage,
})}
>
<div ref={divRef}>
<div className={styles['ticket-text']}>
<h2 className={cn(styles.hero, styleUtils.appear, styleUtils['appear-first'])}>
{sharePage ? (
name ? (
<>{name}’s Ticket</>
) : (
<>{SITE_NAME}</>
)
) : golden ? (
<>
You won a golden ticket! <br /> Claim it now!
</>
) : (
<>
You're in. <br /> Make it unique.
</>
)}
</h2>
<p className={cn(styles.description, styleUtils.appear, styleUtils['appear-second'])}>
{sharePage ? (
<>
Join {name ?? 'us'} on {DATE}.
</>
) : golden ? (
<>Claim your ticket with GitHub and Tweet it to redeem your swag pack!</>
) : (
<>
Generate a unique ticket image with <br className={styleUtils['hide-on-mobile']} />
your GitHub profile.
</>
)}
</p>
</div>
<div className={cn(styleUtils.appear, styleUtils['appear-third'])}>
{!sharePage ? (
<TicketForm
defaultUsername={username ?? undefined}
setTicketGenerationState={setTicketGenerationState}
/>
) : (
<Form sharePage align="Left" />
)}
</div>
</div>
<div className={styles['ticket-visual-wrapper']}>
<div
ref={ticketRef}
className={cn(styles['ticket-visual'], styleUtils.appear, styleUtils['appear-fourth'])}
>
<TicketVisual
username={username ?? undefined}
name={name ?? undefined}
ticketNumber={ticketNumber ?? undefined}
ticketGenerationState={ticketGenerationState}
golden={golden}
/>
</div>
{!sharePage && (
<>
{username ? (
<div>
<div className={styles['ticket-actions']}>
<TicketActions username={username} golden={golden} />
</div>
<div className={styles['ticket-copy']}>
<TicketCopy username={username} />
</div>
</div>
) : (
<div className={styles['ticket-actions-placeholder']} />
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,76 @@
.appear {
opacity: 0;
animation: appear 0.8s cubic-bezier(0.1, 0, 0.175, 1) forwards;
}
.appear-first {
animation-delay: 0.4s;
}
.appear-second {
animation-delay: 0.8s;
}
.appear-third {
animation-delay: 1.2s;
}
.appear-fourth {
animation-delay: 1.6s;
}
.appear-fifth {
animation-delay: 2s;
}
.appear-sixth {
animation-delay: 2.4s;
}
@keyframes appear {
to {
opacity: 1;
}
}
@media (min-width: 1200px) {
.hide-on-desktop {
display: none;
}
}
.show-on-mobile {
display: block;
}
.hide-on-mobile {
display: none;
}
@media (min-width: 768px) {
.show-on-mobile {
display: none;
}
.hide-on-mobile {
display: block;
}
}
.hide-on-tablet {
display: block;
}
.show-on-tablet {
display: none;
}
@media (max-width: 1199px) and (min-width: 768px) {
.show-on-tablet {
display: block;
}
.hide-on-tablet {
display: none;
}
}
+13
View File
@@ -1,2 +1,15 @@
export const APP_NAME = 'Supabase'
export const DESCRIPTION = 'The Open Source Alternative to Firebase.'
// Launch Weeek
export const SAMPLE_TICKET_NUMBER = 1234
export const SITE_URL = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/launch-week`
: 'http://localhost:3000/launch-week'
export const SITE_ORIGIN = new URL(SITE_URL).origin
export const TWITTER_USER_NAME = 'supabase'
export const COOKIE = 'supa-launch-week-ticke-user-id'
export const DATE = 'December 12 8:00 PT | 11:00 ET'
export const TWEET_TEXT = 'Got my ticket for #SupaLaunchWeek 6!'
export const TWEET_TEXT_GOLDEN = `Woop! I got a Golden Ticket for #SupaLaunchWeek 6! SupaSwag on its way!`
export const SITE_NAME = 'Supabase'
+18
View File
@@ -11,3 +11,21 @@ export const generateReadingTime = (text: string) => {
export function capitalize(word: string) {
return word[0].toUpperCase() + word.substring(1).toLowerCase()
}
export function isMobileOrTablet() {
// https://stackoverflow.com/a/8876069/114157
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
return viewportWidth < 1200
}
// Convert numbers or strings to pixel value
// Helpful for styled-jsx when using a prop
// height: ${toPixels(height)}; (supports height={20} and height="20px")
export const toPixels = (value: string | number) => {
if (typeof value === 'number') {
return `${value}px`
}
return value
}
+15
View File
@@ -1545,4 +1545,19 @@ module.exports = [
source: '/docs/guides/platform/disk-usage',
destination: '/docs/guides/platform/database-usage',
},
{
permanent: true,
source: '/sign-in',
destination: 'https://app.supabase.com/sign-in',
},
{
permanent: true,
source: '/sign-up',
destination: 'https://app.supabase.com/sign-up',
},
{
permanent: true,
source: '/forgot-password',
destination: 'https://app.supabase.com/forgot-password',
},
]
-14
View File
@@ -17,20 +17,6 @@ module.exports = [
source: '/docs/:path*',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/:path*`,
},
// rewrite to keep existing ticket urls working
{
source: '/launch-week/tickets/:path',
destination: `${process.env.NEXT_PUBLIC_LAUNCHWEEKSITE_URL}/tickets/:path`,
},
// rewrite to move ticket website to another path
{
source: '/launch-week-register',
destination: `${process.env.NEXT_PUBLIC_LAUNCHWEEKSITE_URL}`,
},
{
source: '/launch-week-register/:path*',
destination: `${process.env.NEXT_PUBLIC_LAUNCHWEEKSITE_URL}/:path*`,
},
{
source: '/new-docs',
destination: `${process.env.NEXT_PUBLIC_REFERENCE_DOCS_URL}`,
+2 -1
View File
@@ -29,7 +29,7 @@
"gray-matter": "^4.0.2",
"markdown-toc": "^1.2.0",
"marked": "^4.0.12",
"next": "latest",
"next": "12.1.0",
"next-compose-plugins": "^2.2.1",
"next-mdx-remote": "^4.1.0",
"next-seo": "^4.20.0",
@@ -55,6 +55,7 @@
"ui": "*",
"use-debounce": "^7.0.1",
"uuid": "^8.3.2",
"vanilla-tilt": "1.7.0",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -0,0 +1,5 @@
import TicketImage from '~/components/LaunchWeek/Ticket/ticket-image'
export default function TicketOnlyPage() {
return <TicketImage />
}
@@ -0,0 +1,114 @@
import { NextSeo } from 'next-seo'
import { GetStaticProps, GetStaticPaths } from 'next'
import Error from 'next/error'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import TicketContainer from '~/components/LaunchWeek/Ticket/TicketContainer'
import { SITE_URL, SAMPLE_TICKET_NUMBER } from '~/lib/constants'
import { createClient } from '@supabase/supabase-js'
import { useState } from 'react'
import { FastGlobOptionsWithoutCwd } from 'globby'
type Props = {
username: string | null
name: string | null
ticketNumber: number | null
golden: boolean
}
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_SECRET!
)
export default function TicketShare({ username, ticketNumber, name, golden }: Props) {
const [supabase] = useState(() =>
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
)
const description = 'Supabase Launch Week 6 | 12-16 Dec 2022'
if (!ticketNumber) {
return <Error statusCode={404} />
}
const ogImageUrl = `https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent(
username ?? ''
)}`
return (
<>
<NextSeo
title={`${name ? name + '’s' : 'Get your'} #SupaLaunchWeek Ticket`}
openGraph={{
title: `${name ? name + '’s' : 'Get your'} #SupaLaunchWeek Ticket`,
description: description,
url: `${SITE_URL}/tickets/${username}`,
images: [
{
url: ogImageUrl,
},
],
}}
/>
<div className="launch-week-gradientBg"></div>
<DefaultLayout>
<SectionContainer className="flex flex-col gap-8 !pb-0 md:gap-16 lg:gap-16">
<img
src="/images/launchweek/launchweek-logo--light.svg"
className="md:40 w-28 dark:hidden lg:w-48"
/>
<img
src="/images/launchweek/launchweek-logo--dark.svg"
className="md:40 hidden w-28 dark:block lg:w-48"
/>
<TicketContainer
supabase={supabase}
session={null}
defaultUserData={{
username: username || undefined,
name: name || '',
ticketNumber,
golden,
}}
sharePage
/>
</SectionContainer>
</DefaultLayout>
</>
)
}
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const username = params?.username?.toString() || null
let name: string | null | undefined
let ticketNumber: number | null | undefined
let golden = false
if (username) {
const { data: user } = await supabaseAdmin!
.from('lw6_tickets_golden')
.select('name, ticketNumber, golden')
.eq('username', username)
.single()
name = user?.name
ticketNumber = user?.ticketNumber
golden = user?.golden ?? false
}
return {
props: {
username: ticketNumber ? username : null,
usernameFromParams: username || null,
name: ticketNumber ? name || username || null : null,
ticketNumber: ticketNumber || SAMPLE_TICKET_NUMBER,
golden,
},
revalidate: 5,
}
}
export const getStaticPaths: GetStaticPaths = async () => {
return Promise.resolve({
paths: [],
fallback: 'blocking',
})
}
@@ -0,0 +1,67 @@
import { NextSeo } from 'next-seo'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import TicketContainer from '~/components/LaunchWeek/Ticket/TicketContainer'
import { SITE_URL } from '~/lib/constants'
import { useRouter } from 'next/router'
import { createClient, Session } from '@supabase/supabase-js'
import { useState, useEffect } from 'react'
export default function TicketHome() {
const [supabase] = useState(() =>
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
)
const [session, setSession] = useState<Session | null>(null)
const description = 'Supabase Launch Week 6 | 12-16 Dec 2022'
const { query } = useRouter()
const ticketNumber = query.ticketNumber?.toString()
const defaultUserData = {
id: query.id?.toString(),
ticketNumber: ticketNumber ? parseInt(ticketNumber, 10) : undefined,
name: query.name?.toString(),
username: query.username?.toString(),
}
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
setSession(session)
})
}, [])
return (
<>
<NextSeo
title={`Get your #SupaLaunchWeek Ticket`}
openGraph={{
title: `Get your #SupaLaunchWeek Ticket`,
description: description,
url: `${SITE_URL}/tickets`,
images: [
{
url: `https://supabase.com/images/launchweek/og-image.jpg`, // TODO
},
],
}}
/>
<div className="launch-week-gradientBg"></div>
<DefaultLayout>
<SectionContainer className="flex flex-col gap-8 !pb-0 md:gap-16 lg:gap-16">
<img
src="/images/launchweek/launchweek-logo--light.svg"
className="md:40 w-28 dark:hidden lg:w-48"
/>
<img
src="/images/launchweek/launchweek-logo--dark.svg"
className="md:40 hidden w-28 dark:block lg:w-48"
/>
<TicketContainer
supabase={supabase}
session={session}
defaultUserData={defaultUserData}
defaultPageState={query.ticketNumber ? 'ticket' : 'registration'}
/>
</SectionContainer>
</DefaultLayout>
</>
)
}
+1
View File
@@ -224,6 +224,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
let { data: partner } = await supabase
.from('partners')
.select('*')
.eq('approved', true)
.eq('slug', params!.slug as string)
.single()
Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

+139 -132
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -84,6 +84,12 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/launch-week/ticket-image</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/legal/dpa</loc>
<changefreq>weekly</changefreq>
@@ -902,6 +908,12 @@
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/blog/flutter-authentication-and-authorization-with-rls</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
<url>
<loc>https://supabase.com/alternatives/supabase-vs-firebase</loc>
<changefreq>weekly</changefreq>
+1
View File
@@ -373,6 +373,7 @@ launch week
:root {
--auth-ui-card-height: 50vh;
--lw-secondary-color: #8a8f98;
}
.glow-area {
@@ -0,0 +1,24 @@
# Writing functions using Oak Server Middleware
This example shows how you can write functions using Oak server middleware (https://oakserver.github.io/oak/)
## Run locally
```bash
supabase functions serve oak-server
```
Use cURL or Postman to make a POST request to http://localhost:54321/functions/v1/oak-server.
```
curl --location --request POST 'http://localhost:54321/functions/v1/oak-server' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--header 'Content-Type: application/json' \
--data-raw '{ "name": "John Doe" }'
```
## Deploy
```bash
supabase functions deploy oak-server
```
@@ -0,0 +1,25 @@
import { Application, Router } from 'https://deno.land/x/oak/mod.ts'
const router = new Router()
router
// Note: path will be prefixed with function name
.get('/oak-server', (context) => {
context.response.body = 'This is an example Oak server running on Edge Functions!'
})
.post('/oak-server/greet', (context) => {
// Note: request body will be streamed to the function as chunks, set limit to 0 to fully read it.
const result = context.request.body({ type: 'json', limit: 0 })
const body = await result.value
const name = body.name || 'you'
context.response.body = { msg: `Hey ${name}!` }
})
.get('/oak-server/redirect', (context) => {
context.response.redirect('https://www.example.com')
})
const app = new Application()
app.use(router.routes())
app.use(router.allowedMethods())
await app.listen({ port: 8000 })
@@ -30,6 +30,7 @@
.pub-cache/
.pub/
/build/
pubspec.lock
# Web related
lib/generated_plugin_registrant.dart
@@ -1,516 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
app_links:
dependency: transitive
description:
name: app_links
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
app_links_macos:
dependency: transitive
description:
name: app_links_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
app_links_windows:
dependency: transitive
description:
name: app_links_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
async:
dependency: transitive
description:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
characters:
dependency: transitive
description:
name: characters
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+1"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.2"
fake_async:
dependency: transitive
description:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functions_client:
dependency: transitive
description:
name: functions_client
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
gotrue:
dependency: transitive
description:
name: gotrue
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
hive:
dependency: transitive
description:
name: hive
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.3"
hive_flutter:
dependency: transitive
description:
name: hive_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
http:
dependency: transitive
description:
name: http
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.5"
http_parser:
dependency: transitive
description:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
image_picker:
dependency: "direct main"
description:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+1"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.8"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.5+6"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.6.1"
js:
dependency: transitive
description:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.4"
jwt_decode:
dependency: transitive
description:
name: jwt_decode
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.1"
lint:
dependency: "direct dev"
description:
name: lint
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.12"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.5"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
path_provider:
dependency: transitive
description:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.11"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.17"
path_provider_ios:
dependency: transitive
description:
name: path_provider_ios
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.11"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.7"
path_provider_macos:
dependency: transitive
description:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
postgrest:
dependency: transitive
description:
name: postgrest
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
process:
dependency: transitive
description:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.4"
realtime_client:
dependency: transitive
description:
name: realtime_client
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
storage_client:
dependency: transitive
description:
name: storage_client
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
supabase:
dependency: transitive
description:
name: supabase
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
supabase_flutter:
dependency: "direct main"
description:
name: supabase_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.12"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
universal_io:
dependency: transitive
description:
name: universal_io
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
url_launcher:
dependency: transitive
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.5"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
vector_math:
dependency: transitive
description:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0+1"
sdks:
dart: ">=2.17.0 <3.0.0"
flutter: ">=3.0.0"
@@ -13,7 +13,7 @@ dependencies:
sdk: flutter
image_picker: ^0.8.4
supabase_flutter: ^1.0.0
supabase_flutter: ^1.2.0
dev_dependencies:
flutter_test:
+292 -54
View File
@@ -41,7 +41,7 @@
"@algolia/autocomplete-js": "^1.7.2",
"@algolia/autocomplete-plugin-recent-searches": "^1.7.2",
"@apollo/client": "^3.6.6",
"@docsearch/react": "1.0.0-alpha.27",
"@docsearch/react": "^3.3.0",
"@mdx-js/loader": "^1.6.22",
"@mdx-js/react": "^1.6.22",
"@next/mdx": "^12.0.4",
@@ -228,7 +228,7 @@
"gray-matter": "^4.0.2",
"markdown-toc": "^1.2.0",
"marked": "^4.0.12",
"next": "latest",
"next": "12.1.0",
"next-compose-plugins": "^2.2.1",
"next-mdx-remote": "^4.1.0",
"next-seo": "^4.20.0",
@@ -254,6 +254,7 @@
"ui": "*",
"use-debounce": "^7.0.1",
"uuid": "^8.3.2",
"vanilla-tilt": "1.7.0",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -2820,25 +2821,62 @@
}
},
"node_modules/@docsearch/css": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-1.0.0-alpha.28.tgz",
"integrity": "sha512-1AhRzVdAkrWwhaxTX6/R7SnFHz8yLz1W8I/AldlTrfbNvZs9INk1FZiEFTJdgHaP68nhgQNWSGlQiDiI3y2RYg=="
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.3.0.tgz",
"integrity": "sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg=="
},
"node_modules/@docsearch/react": {
"version": "1.0.0-alpha.27",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-1.0.0-alpha.27.tgz",
"integrity": "sha512-jcgUHZsrNNRsaVsplqKhXWheh4VzRTCdhsPuVhJMRvfsFUqXEPo/7kVt5xIybtOj9u+/FVdeSO+APJEE2rakYA==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.3.0.tgz",
"integrity": "sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A==",
"dependencies": {
"@docsearch/css": "^1.0.0-alpha.27",
"@francoischalifour/autocomplete-core": "^1.0.0-alpha.27",
"@francoischalifour/autocomplete-preset-algolia": "^1.0.0-alpha.27",
"@algolia/autocomplete-core": "1.7.2",
"@algolia/autocomplete-preset-algolia": "1.7.2",
"@docsearch/css": "3.3.0",
"algoliasearch": "^4.0.0"
},
"peerDependencies": {
"react": "^16.8.0",
"react-dom": "^16.8.0"
"@types/react": ">= 16.8.0 < 19.0.0",
"react": ">= 16.8.0 < 19.0.0",
"react-dom": ">= 16.8.0 < 19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@docsearch/react/node_modules/@algolia/autocomplete-core": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz",
"integrity": "sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw==",
"dependencies": {
"@algolia/autocomplete-shared": "1.7.2"
}
},
"node_modules/@docsearch/react/node_modules/@algolia/autocomplete-preset-algolia": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz",
"integrity": "sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw==",
"dependencies": {
"@algolia/autocomplete-shared": "1.7.2"
},
"peerDependencies": {
"@algolia/client-search": ">= 4.9.1 < 6",
"algoliasearch": ">= 4.9.1 < 6"
}
},
"node_modules/@docsearch/react/node_modules/@algolia/autocomplete-shared": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz",
"integrity": "sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug=="
},
"node_modules/@eslint/eslintrc": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz",
@@ -2888,16 +2926,6 @@
"npm": ">=6.0.0"
}
},
"node_modules/@francoischalifour/autocomplete-core": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@francoischalifour/autocomplete-core/-/autocomplete-core-1.0.0-alpha.28.tgz",
"integrity": "sha512-rL9x+72btViw+9icfBKUJjZj87FgjFrD2esuTUqtj4RAX3s4AuVZiN8XEsfjQBSc6qJk31cxlvqZHC/BIyYXgg=="
},
"node_modules/@francoischalifour/autocomplete-preset-algolia": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@francoischalifour/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.0.0-alpha.28.tgz",
"integrity": "sha512-bprfNmYt1opFUFEtD2XfY/kEsm13bzHQgU80uMjhuK0DJ914IjolT1GytpkdM6tJ4MBvyiJPP+bTtWO+BZ7c7w=="
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -9496,6 +9524,75 @@
"tailwindcss": ">=2.0.0"
}
},
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.1.1.tgz",
"integrity": "sha512-IdmEekEYxQsoLOR0XQyw3jD1GujBpRRYaGJYQUw1eOT1eUugWxdc7jomh1VQ1EKHcdwDLpLaCz/8y4KraU4T9A==",
"dependencies": {
"remove-accents": "0.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kentcdodds"
}
},
"node_modules/@tanstack/query-core": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.15.1.tgz",
"integrity": "sha512-+UfqJsNbPIVo0a9ANW0ZxtjiMfGLaaoIaL9vZeVycvmBuWywJGtSi7fgPVMCPdZQFOzMsaXaOsDtSKQD5xLRVQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.16.1.tgz",
"integrity": "sha512-PDE9u49wSDykPazlCoLFevUpceLjQ0Mm8i6038HgtTEKb/aoVnUZdlUP7C392ds3Cd75+EGlHU7qpEX06R7d9Q==",
"dependencies": {
"@tanstack/query-core": "4.15.1",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.16.1.tgz",
"integrity": "sha512-VrDYLmG+OOcvGSZL5avG4R8jhqeMFP7pzW2sh2BWEV9UfI+aocG+CW8y8ygacxuKy48m8Tyo/xfe8H1z9BGb+g==",
"dependencies": {
"@tanstack/match-sorter-utils": "8.1.1",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "4.16.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@testdeck/core": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@testdeck/core/-/core-0.2.2.tgz",
@@ -13563,6 +13660,20 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"dev": true
},
"node_modules/copy-anything": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz",
"integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==",
"dependencies": {
"is-what": "^4.1.6"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-concurrently": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -20395,6 +20506,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-what": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz",
"integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-whitespace-character": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
@@ -31101,6 +31223,11 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
},
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -32917,6 +33044,17 @@
"resolved": "tests",
"link": true
},
"node_modules/superjson": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.11.0.tgz",
"integrity": "sha512-6PfAg1FKhqkwWvPb2uXhH4MkMttdc17eJ91+Aoz4s1XUEDZFmLfFx/xVA3wgkPxAGy5dpozgGdK6V/n20Wj9yg==",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -34999,6 +35137,14 @@
"react": "^16.8.0 || ^17.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
@@ -35157,6 +35303,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/vanilla-tilt": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vanilla-tilt/-/vanilla-tilt-1.7.0.tgz",
"integrity": "sha512-u9yUhpSasFeqQCuiTon+RSb0aHzcj9stvWVXQIswzKL5oG491lkYk7U1GmhOAEZt7yPT6EiYZRJhIh2MFBncOA=="
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -36857,7 +37008,9 @@
"@supabase/postgres-meta": "^0.26.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.0",
"@supabase/supabase-js": "^2.1.1",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
@@ -36961,7 +37114,7 @@
"storybook-dark-mode": "^1.0.4",
"tailwindcss": "^3.0.22",
"ts-jest": "^27.0.7",
"typescript": "4.3.4"
"typescript": "4.8.4"
}
},
"studio/node_modules/@next/bundle-analyzer": {
@@ -37418,9 +37571,9 @@
}
},
"studio/node_modules/typescript": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz",
"integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==",
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
"integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
@@ -39308,19 +39461,42 @@
"dev": true
},
"@docsearch/css": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-1.0.0-alpha.28.tgz",
"integrity": "sha512-1AhRzVdAkrWwhaxTX6/R7SnFHz8yLz1W8I/AldlTrfbNvZs9INk1FZiEFTJdgHaP68nhgQNWSGlQiDiI3y2RYg=="
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.3.0.tgz",
"integrity": "sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg=="
},
"@docsearch/react": {
"version": "1.0.0-alpha.27",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-1.0.0-alpha.27.tgz",
"integrity": "sha512-jcgUHZsrNNRsaVsplqKhXWheh4VzRTCdhsPuVhJMRvfsFUqXEPo/7kVt5xIybtOj9u+/FVdeSO+APJEE2rakYA==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.3.0.tgz",
"integrity": "sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A==",
"requires": {
"@docsearch/css": "^1.0.0-alpha.27",
"@francoischalifour/autocomplete-core": "^1.0.0-alpha.27",
"@francoischalifour/autocomplete-preset-algolia": "^1.0.0-alpha.27",
"@algolia/autocomplete-core": "1.7.2",
"@algolia/autocomplete-preset-algolia": "1.7.2",
"@docsearch/css": "3.3.0",
"algoliasearch": "^4.0.0"
},
"dependencies": {
"@algolia/autocomplete-core": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz",
"integrity": "sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw==",
"requires": {
"@algolia/autocomplete-shared": "1.7.2"
}
},
"@algolia/autocomplete-preset-algolia": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz",
"integrity": "sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw==",
"requires": {
"@algolia/autocomplete-shared": "1.7.2"
}
},
"@algolia/autocomplete-shared": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz",
"integrity": "sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug=="
}
}
},
"@eslint/eslintrc": {
@@ -39360,16 +39536,6 @@
"integrity": "sha512-8YXBE2ZcU/pImVOHX7MWrSR/X5up7t6rPWZlk34RwZEcdr3ua6X+32pSd6XuOQRN+vbuvYNfA6iey8NbrjuMFQ==",
"dev": true
},
"@francoischalifour/autocomplete-core": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@francoischalifour/autocomplete-core/-/autocomplete-core-1.0.0-alpha.28.tgz",
"integrity": "sha512-rL9x+72btViw+9icfBKUJjZj87FgjFrD2esuTUqtj4RAX3s4AuVZiN8XEsfjQBSc6qJk31cxlvqZHC/BIyYXgg=="
},
"@francoischalifour/autocomplete-preset-algolia": {
"version": "1.0.0-alpha.28",
"resolved": "https://registry.npmjs.org/@francoischalifour/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.0.0-alpha.28.tgz",
"integrity": "sha512-bprfNmYt1opFUFEtD2XfY/kEsm13bzHQgU80uMjhuK0DJ914IjolT1GytpkdM6tJ4MBvyiJPP+bTtWO+BZ7c7w=="
},
"@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -44312,6 +44478,38 @@
"lodash.uniq": "^4.5.0"
}
},
"@tanstack/match-sorter-utils": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.1.1.tgz",
"integrity": "sha512-IdmEekEYxQsoLOR0XQyw3jD1GujBpRRYaGJYQUw1eOT1eUugWxdc7jomh1VQ1EKHcdwDLpLaCz/8y4KraU4T9A==",
"requires": {
"remove-accents": "0.4.2"
}
},
"@tanstack/query-core": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.15.1.tgz",
"integrity": "sha512-+UfqJsNbPIVo0a9ANW0ZxtjiMfGLaaoIaL9vZeVycvmBuWywJGtSi7fgPVMCPdZQFOzMsaXaOsDtSKQD5xLRVQ=="
},
"@tanstack/react-query": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.16.1.tgz",
"integrity": "sha512-PDE9u49wSDykPazlCoLFevUpceLjQ0Mm8i6038HgtTEKb/aoVnUZdlUP7C392ds3Cd75+EGlHU7qpEX06R7d9Q==",
"requires": {
"@tanstack/query-core": "4.15.1",
"use-sync-external-store": "^1.2.0"
}
},
"@tanstack/react-query-devtools": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.16.1.tgz",
"integrity": "sha512-VrDYLmG+OOcvGSZL5avG4R8jhqeMFP7pzW2sh2BWEV9UfI+aocG+CW8y8ygacxuKy48m8Tyo/xfe8H1z9BGb+g==",
"requires": {
"@tanstack/match-sorter-utils": "8.1.1",
"superjson": "^1.10.0",
"use-sync-external-store": "^1.2.0"
}
},
"@testdeck/core": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@testdeck/core/-/core-0.2.2.tgz",
@@ -47609,6 +47807,14 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"dev": true
},
"copy-anything": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz",
"integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==",
"requires": {
"is-what": "^4.1.6"
}
},
"copy-concurrently": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -49232,7 +49438,7 @@
"@algolia/autocomplete-js": "^1.7.2",
"@algolia/autocomplete-plugin-recent-searches": "^1.7.2",
"@apollo/client": "^3.6.6",
"@docsearch/react": "1.0.0-alpha.27",
"@docsearch/react": "^3.3.0",
"@mdx-js/loader": "^1.6.22",
"@mdx-js/react": "^1.6.22",
"@next/mdx": "^12.0.4",
@@ -52995,6 +53201,11 @@
"get-intrinsic": "^1.1.1"
}
},
"is-what": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz",
"integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ=="
},
"is-whitespace-character": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz",
@@ -61035,6 +61246,11 @@
}
}
},
"remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
},
"remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -62428,8 +62644,10 @@
"@supabase/postgres-meta": "^0.26.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.0",
"@supabase/supabase-js": "^2.1.1",
"@tailwindcss/typography": "^0.5.2",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
@@ -62514,7 +62732,7 @@
"swr": "^1.2.1",
"tailwindcss": "^3.0.22",
"ts-jest": "^27.0.7",
"typescript": "4.3.4",
"typescript": "4.8.4",
"ui": "*",
"uniforms-bootstrap4": "^3.6.2",
"uniforms-bridge-json-schema": "^3.6.2",
@@ -62813,9 +63031,9 @@
"requires": {}
},
"typescript": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz",
"integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==",
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
"integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
"dev": true
},
"webpack-bundle-analyzer": {
@@ -63000,6 +63218,14 @@
}
}
},
"superjson": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.11.0.tgz",
"integrity": "sha512-6PfAg1FKhqkwWvPb2uXhH4MkMttdc17eJ91+Aoz4s1XUEDZFmLfFx/xVA3wgkPxAGy5dpozgGdK6V/n20Wj9yg==",
"requires": {
"copy-anything": "^3.0.2"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -64610,6 +64836,12 @@
"object-assign": "^4.1.1"
}
},
"use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"requires": {}
},
"utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
@@ -64746,6 +64978,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"vanilla-tilt": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vanilla-tilt/-/vanilla-tilt-1.7.0.tgz",
"integrity": "sha512-u9yUhpSasFeqQCuiTon+RSb0aHzcj9stvWVXQIswzKL5oG491lkYk7U1GmhOAEZt7yPT6EiYZRJhIh2MFBncOA=="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -65771,7 +66008,7 @@
"gray-matter": "^4.0.2",
"markdown-toc": "^1.2.0",
"marked": "^4.0.12",
"next": "latest",
"next": "12.1.0",
"next-compose-plugins": "^2.2.1",
"next-mdx-remote": "^4.1.0",
"next-seo": "^4.20.0",
@@ -65802,6 +66039,7 @@
"ui": "*",
"use-debounce": "^7.0.1",
"uuid": "^8.3.2",
"vanilla-tilt": "1.7.0",
"yup": "^0.32.11"
},
"dependencies": {
+18 -6
View File
@@ -836,7 +836,6 @@ pages:
description: |
Retrieves the details of all Storage buckets within an existing product.
title: 'listBuckets()'
$ref: '@supabase/storage-js.packages/StorageBucketApi.default.listBuckets'
notes: |
- Policy permissions required:
- `buckets` permissions: `select`
@@ -855,7 +854,6 @@ pages:
description: |
Retrieves the details of an existing Storage bucket.
title: 'getBucket()'
$ref: '@supabase/storage-js.packages/StorageBucketApi.default.getBucket'
notes: |
- Policy permissions required:
- `buckets` permissions: `select`
@@ -945,7 +943,6 @@ pages:
description: |
Uploads a file to an existing bucket.
title: 'from.upload()'
$ref: '@supabase/storage-js.packages/StorageFileApi.default.upload'
notes: |
- Policy permissions required:
- `buckets` permissions: none
@@ -1022,6 +1019,24 @@ pages:
.createSignedUrl('avatar1.png', 60);
```
storage.from.createSignedUrls():
title: 'from.createSignedUrls()'
notes: |
- RLS policy permissions required:
- `buckets` table permissions: none
- `objects` table permissions: `select`
- Refer to the [Storage guide](/docs/guides/storage#access-control) on how access control works
examples:
- name: Create Signed URLs
isSpotlight: true
dart: |
```dart
final List<String> signedUrls = await supabase
.storage
.from('avatars')
.createSignedUrls(['folder/avatar1.png', 'folder/avatar2.png'], 60);
```
storage.from.getPublicUrl():
description: |
Retrieve URLs for assets in public buckets
@@ -1671,7 +1686,6 @@ pages:
.lte():
description: |
Finds all rows whose value on the stated `column` is less than or equal to the specified `value`.
$ref: '@supabase/postgrest-js.PostgrestFilterBuilder.lte'
examples:
- name: With `select()`
isSpotlight: true
@@ -1710,7 +1724,6 @@ pages:
.like():
description: |
Finds all rows whose value in the stated `column` matches the supplied `pattern` (case sensitive).
$ref: '@supabase/postgrest-js.PostgrestFilterBuilder.lte'
examples:
- name: With `select()`
isSpotlight: true
@@ -2045,7 +2058,6 @@ pages:
```
.rangeLte():
$ref: '@supabase/postgrest-js.PostgrestFilterBuilder.rangeLte'
examples:
- name: With `select()`
isSpotlight: true
@@ -91,3 +91,39 @@ export const formatPITROptions = (addons: DatabaseAddon[]) => {
if (pitrOptions.length === 0) return []
else return [noPITROption].concat(pitrOptions)
}
export const formatCustomDomainOptions = (addons: DatabaseAddon[]) => {
const customDomainOrder = ['addon_custom_domains']
const noCustomDomainOption: DatabaseAddon = {
id: undefined,
name: 'Disable Custom Domains',
metadata: {
default_price_id: undefined,
supabase_prod_id: 'addon_custom_domains_disabled',
features: '',
},
prices: [
{
id: undefined,
currency: 'usd',
recurring: {
interval: 'month',
usage_type: 'licensed',
interval_count: 1,
aggregate_usage: null,
trial_period_days: null,
},
unit_amount: 0,
},
],
}
const customDomainOptions = customDomainOrder
.map((id: string) => {
return addons.find((option) => option.metadata.supabase_prod_id === id)
})
.filter((option) => option !== undefined) as DatabaseAddon[]
if (customDomainOptions.length === 0) return []
else return [noCustomDomainOption].concat(customDomainOptions)
}
@@ -0,0 +1,70 @@
import { FC } from 'react'
import { Badge, Radio } from 'ui'
import { useStore, useFlag } from 'hooks'
import { getProductPrice } from '../Billing.utils'
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
interface Props {
options: any[]
currentOption?: any
selectedOption: any
onSelectOption: (option: any) => void
}
const CustomDomainSelection: FC<Props> = ({
options,
currentOption,
selectedOption,
onSelectOption,
}) => {
const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
return (
<div className="space-y-4">
<div>
<div className="flex items-center space-x-2">
<h4 className="text-lg">Custom Domains</h4>
<Badge color="green">Optional</Badge>
</div>
<p className="text-sm text-scale-1100">Present a branded experience to your users</p>
</div>
{addonUpdateDisabled ? (
<DisabledWarningDueToIncident title="Updating database add-ons is currently disabled" />
) : (
<Radio.Group type="cards" className="billing-compute-radio">
{options.map((option: any) => {
const defaultPrice = getProductPrice(option)
return (
<Radio
hidden
key={option.id || 'micro-add-on'}
align="vertical"
// @ts-ignore
label={
<div className="flex items-center space-x-4">
<p>{option.name}</p>
{currentOption?.id === option.id && (
<Badge color="brand">Current selection</Badge>
)}
</div>
}
// @ts-ignore
description={
<div>
<p>{option.metadata.features}</p>
</div>
}
value={option.id}
optionalLabel={<div>${defaultPrice.unit_amount / 100} / month</div>}
checked={selectedOption?.id === option.id}
onChange={() => onSelectOption(option)}
/>
)
})}
</Radio.Group>
)}
</div>
)
}
export default CustomDomainSelection
@@ -29,7 +29,7 @@ const PITRDurationSelection: FC<Props> = ({
const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
// Only projects of version greater than supabase-postgrest-14.1.0.44 can use PITR
const canUsePITR = getSemanticVersion(ui.selectedProject?.dbVersion ?? '') > 141044
const canUsePITR = getSemanticVersion(ui.selectedProject?.dbVersion ?? '') >= 141044
return (
<div className="space-y-4">
@@ -1,4 +1,4 @@
import { IconArchive, IconDatabase, IconKey } from 'ui'
import { IconArchive, IconCode, IconDatabase, IconKey } from 'ui'
export const CANCELLATION_REASONS = [
'Pricing',
@@ -71,4 +71,25 @@ export const USAGE_BASED_PRODUCTS = [
},
],
},
{
title: 'Functions',
icon: <IconCode className="dark:text-scale-100" size={16} strokeWidth={2} />,
features: [
{
key: 'func_count',
attribute: 'func_count',
title: 'Function Count',
units: 'absolute',
costPerUnit: 0.1,
},
{
key: 'func_invocations',
attribute: 'func_invocations',
title: 'Function Invocations',
units: 'absolute',
costPerUnit: 0.000002,
}
],
},
]
@@ -1,5 +1,9 @@
import { DatabaseAddon } from './AddOns/AddOns.types'
import { formatComputeSizes, formatPITROptions } from './AddOns/AddOns.utils'
import {
formatComputeSizes,
formatCustomDomainOptions,
formatPITROptions,
} from './AddOns/AddOns.utils'
import { StripeSubscription } from './Subscription/Subscription.types'
export const getProductPrice = (product: any) => {
@@ -11,11 +15,15 @@ export const getProductPrice = (product: any) => {
return price
}
export const validateSubscriptionUpdatePayload = (
selectedComputeSize: DatabaseAddon,
selectedPITRDuration?: DatabaseAddon
) => {
if (selectedPITRDuration?.id !== undefined && selectedComputeSize.id === undefined) {
export const validateSubscriptionUpdatePayload = (selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
}) => {
if (
selectedAddons.pitrDuration?.id !== undefined &&
selectedAddons.computeSize.id === undefined
) {
return 'To enable PITR for your project, your project must minimally be on a Small Add-on.'
}
return undefined
@@ -23,18 +31,24 @@ export const validateSubscriptionUpdatePayload = (
export const formSubscriptionUpdatePayload = (
selectedTier: any,
selectedComputeSize: DatabaseAddon,
selectedPITRDuration: DatabaseAddon | undefined,
selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
},
selectedPaymentMethod: string,
region: string
) => {
const { computeSize, pitrDuration, customDomains } = selectedAddons
const defaultPrice = selectedTier ? getProductPrice(selectedTier) : undefined
const addons =
region === 'af-south-1'
? []
: [selectedComputeSize.prices[0].id, selectedPITRDuration?.prices?.[0].id].filter(
(x) => x !== undefined
)
: [
computeSize.prices[0].id,
pitrDuration?.prices?.[0].id,
customDomains?.prices?.[0].id,
].filter((x) => x !== undefined)
const proration_date = Math.floor(Date.now() / 1000)
return {
...(defaultPrice && { tier: defaultPrice.id }),
@@ -50,8 +64,9 @@ export const getCurrentAddons = (
) => {
const computeSizes = formatComputeSizes(addons)
const pitrDurationOptions = formatPITROptions(addons)
const customDomainOptions = formatCustomDomainOptions(addons)
const currentComputeSize =
const computeSize =
computeSizes.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_instance_')
@@ -62,7 +77,7 @@ export const getCurrentAddons = (
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_micro'
) as DatabaseAddon)
const currentPITRDuration =
const pitrDuration =
pitrDurationOptions.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_pitr_')
@@ -73,5 +88,16 @@ export const getCurrentAddons = (
(option: any) => option.metadata.supabase_prod_id === 'addon_pitr_0days'
) as DatabaseAddon)
return { currentComputeSize, currentPITRDuration }
const customDomains =
customDomainOptions.find((option: any) => {
const subscriptionComputeSize = currentSubscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_custom_domains')
)
return option.id === subscriptionComputeSize?.prod_id
}) ||
(customDomainOptions.find(
(option: any) => option.metadata.supabase_prod_id === 'addon__custom_domains_disabled'
) as DatabaseAddon)
return { computeSize, pitrDuration, customDomains }
}
@@ -2,14 +2,18 @@ import { FC, useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { Transition } from '@headlessui/react'
import { useStore } from 'hooks'
import { useStore, useFlag } from 'hooks'
import { getURL } from 'lib/helpers'
import { post, patch } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import Divider from 'components/ui/Divider'
import { DatabaseAddon } from './AddOns/AddOns.types'
import { formatComputeSizes, formatPITROptions } from './AddOns/AddOns.utils'
import {
formatComputeSizes,
formatCustomDomainOptions,
formatPITROptions,
} from './AddOns/AddOns.utils'
import {
AddNewPaymentMethodModal,
ComputeSizeSelection,
@@ -17,6 +21,7 @@ import {
StripeSubscription,
PaymentSummaryPanel,
UpdateSuccess,
CustomDomainSelection,
} from './'
import { PaymentMethod, SubscriptionPreview } from './Billing.types'
import { formSubscriptionUpdatePayload, getCurrentAddons } from './Billing.utils'
@@ -36,6 +41,8 @@ const EnterpriseUpdate: FC<Props> = ({
}) => {
const { app, ui } = useStore()
const router = useRouter()
const isCustomDomainsEnabled = useFlag('customDomains')
const isPITRSelfServeEnabled = useFlag('pitrSelfServe')
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref ?? 'default'
@@ -44,12 +51,30 @@ const EnterpriseUpdate: FC<Props> = ({
const { addons } = products
const computeSizes = formatComputeSizes(addons)
const pitrDurationOptions = formatPITROptions(addons)
const { currentComputeSize, currentPITRDuration } = getCurrentAddons(currentSubscription, addons)
const customDomainOptions = formatCustomDomainOptions(addons)
const currentAddons = getCurrentAddons(currentSubscription, addons)
// [Joshen TODO] Ideally we just have a state to hold all the add ons selection, rather than individual
// Even better if we can just use the <Form> component to handle all of these. Mainly to reduce the amount
// of unnecessary state management on this complex page.
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(
currentAddons.computeSize
)
const [selectedPITRDuration, setSelectedPITRDuration] = useState<DatabaseAddon>(
currentAddons.pitrDuration
)
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<DatabaseAddon>(
currentAddons.customDomains
)
// [Joshen] Scaffolded here
const selectedAddons = {
computeSize: selectedComputeSize,
pitrDuration: selectedPITRDuration,
customDomains: selectedCustomDomainOption,
}
const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState<any>()
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(currentComputeSize)
const [selectedPITRDuration, setSelectedPITRDuration] =
useState<DatabaseAddon>(currentPITRDuration)
const [subscriptionPreview, setSubscriptionPreview] = useState<SubscriptionPreview>()
const [isRefreshingPreview, setIsRefreshingPreview] = useState(false)
@@ -57,7 +82,7 @@ const EnterpriseUpdate: FC<Props> = ({
const [isSuccessful, setIsSuccessful] = useState(false)
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
const isChangingComputeSize = currentComputeSize?.id !== selectedComputeSize.id
const isChangingComputeSize = currentAddons.computeSize?.id !== selectedAddons.computeSize.id
useEffect(() => {
getSubscriptionPreview()
@@ -75,8 +100,7 @@ const EnterpriseUpdate: FC<Props> = ({
const payload = {
...formSubscriptionUpdatePayload(
null,
selectedComputeSize,
selectedPITRDuration,
selectedAddons,
selectedPaymentMethodId,
projectRegion
),
@@ -100,8 +124,7 @@ const EnterpriseUpdate: FC<Props> = ({
const payload = {
...formSubscriptionUpdatePayload(
null,
selectedComputeSize,
selectedPITRDuration,
selectedAddons,
selectedPaymentMethodId,
projectRegion
),
@@ -176,13 +199,24 @@ const EnterpriseUpdate: FC<Props> = ({
</div>
{projectRegion !== 'af-south-1' && (
<>
{pitrDurationOptions.length > 0 && (
{isCustomDomainsEnabled && customDomainOptions.length > 0 && (
<>
<Divider light />
<CustomDomainSelection
options={customDomainOptions}
currentOption={currentAddons.customDomains}
selectedOption={selectedAddons.customDomains}
onSelectOption={setSelectedCustomDomainOption}
/>
</>
)}
{isPITRSelfServeEnabled && pitrDurationOptions.length > 0 && (
<>
<Divider light />
<PITRDurationSelection
pitrDurationOptions={pitrDurationOptions}
currentPitrDuration={currentPITRDuration}
selectedPitrDuration={selectedPITRDuration}
currentPitrDuration={currentAddons.pitrDuration}
selectedPitrDuration={selectedAddons.pitrDuration}
onSelectOption={setSelectedPITRDuration}
/>
</>
@@ -190,8 +224,8 @@ const EnterpriseUpdate: FC<Props> = ({
<Divider light />
<ComputeSizeSelection
computeSizes={computeSizes || []}
currentComputeSize={currentComputeSize}
selectedComputeSize={selectedComputeSize}
currentComputeSize={currentAddons.computeSize}
selectedComputeSize={selectedAddons.computeSize}
onSelectOption={setSelectedComputeSize}
/>
</>
@@ -208,11 +242,9 @@ const EnterpriseUpdate: FC<Props> = ({
subscriptionPreview={subscriptionPreview}
// Current subscription configuration based on DB
currentPlan={currentSubscription.tier}
currentComputeSize={currentComputeSize}
currentPITRDuration={currentPITRDuration}
currentAddons={currentAddons}
// Selected subscription configuration based on UI
selectedComputeSize={selectedComputeSize}
selectedPITRDuration={selectedPITRDuration}
selectedAddons={selectedAddons}
paymentMethods={paymentMethods}
isLoadingPaymentMethods={isLoadingPaymentMethods}
selectedPaymentMethod={selectedPaymentMethodId}
@@ -18,12 +18,21 @@ import ConfirmPaymentModal from './ConfirmPaymentModal'
interface Props {
isRefreshingPreview: boolean
subscriptionPreview?: SubscriptionPreview
currentPlan: any
currentComputeSize: DatabaseAddon
currentPITRDuration: DatabaseAddon | undefined
currentAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
}
selectedPlan?: any
selectedComputeSize: DatabaseAddon
selectedPITRDuration: DatabaseAddon | undefined
selectedAddons: {
computeSize: DatabaseAddon
pitrDuration: DatabaseAddon
customDomains: DatabaseAddon
}
isSpendCapEnabled: boolean
paymentMethods?: any
selectedPaymentMethod: any
@@ -45,12 +54,13 @@ const PaymentSummaryPanel: FC<Props> = ({
isRefreshingPreview,
isSpendCapEnabled,
subscriptionPreview,
currentPlan,
currentComputeSize,
currentPITRDuration,
currentAddons,
selectedPlan,
selectedComputeSize,
selectedPITRDuration,
selectedAddons,
paymentMethods,
selectedPaymentMethod,
isLoadingPaymentMethods,
@@ -70,9 +80,10 @@ const PaymentSummaryPanel: FC<Props> = ({
const [showConfirmModal, setShowConfirmModal] = useState(false)
const currentPITRDays = currentPITRDuration !== undefined ? getPITRDays(currentPITRDuration) : 0
const currentPITRDays =
currentAddons.pitrDuration !== undefined ? getPITRDays(currentAddons.pitrDuration) : 0
const selectedPITRDays =
selectedPITRDuration !== undefined ? getPITRDays(selectedPITRDuration) : 0
selectedAddons.pitrDuration !== undefined ? getPITRDays(selectedAddons.pitrDuration) : 0
const isEnterprise = currentPlan.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
const isChangingPlan =
@@ -81,8 +92,10 @@ const PaymentSummaryPanel: FC<Props> = ({
currentPlan.prod_id !== selectedPlan.id) ||
(currentPlan.prod_id !== STRIPE_PRODUCT_IDS.PAYG && !isSpendCapEnabled) ||
(currentPlan.prod_id === STRIPE_PRODUCT_IDS.PAYG && isSpendCapEnabled)
const isChangingComputeSize = currentComputeSize.id !== selectedComputeSize.id
const isChangingPITRDuration = currentPITRDuration?.id !== selectedPITRDuration?.id
const isChangingComputeSize = currentAddons.computeSize.id !== selectedAddons.computeSize.id
const isChangingPITRDuration = currentAddons.pitrDuration?.id !== selectedAddons.pitrDuration?.id
const isChangingCustomDomains =
currentAddons.customDomains?.id !== selectedAddons.customDomains?.id
// If it's enterprise we only only changing of add-ons
const hasChangesToPlan = isEnterprise
@@ -96,7 +109,7 @@ const PaymentSummaryPanel: FC<Props> = ({
}
const validateOrder = () => {
const error = validateSubscriptionUpdatePayload(selectedComputeSize, selectedPITRDuration)
const error = validateSubscriptionUpdatePayload(selectedAddons)
if (error) {
return ui.setNotification({
duration: 4000,
@@ -147,45 +160,71 @@ const PaymentSummaryPanel: FC<Props> = ({
<p
className={`${isChangingComputeSize ? 'text-scale-1100 line-through' : ''} text-sm`}
>
{currentComputeSize.name}
{currentAddons.computeSize.name}
</p>
<p
className={`${isChangingComputeSize ? 'text-scale-1100 line-through' : ''} text-sm`}
>
${(getProductPrice(currentComputeSize).unit_amount / 100).toFixed(2)}
${(getProductPrice(currentAddons.computeSize).unit_amount / 100).toFixed(2)}
</p>
</div>
{isChangingComputeSize && (
<div className="flex items-center justify-between">
<p className="text-sm">{selectedComputeSize.name}</p>
<p className="text-sm">{selectedAddons.computeSize.name}</p>
<p className="text-sm">
${(getProductPrice(selectedComputeSize).unit_amount / 100).toFixed(2)}
${(getProductPrice(selectedAddons.computeSize).unit_amount / 100).toFixed(2)}
</p>
</div>
)}
{currentPITRDuration?.id !== undefined && (
{currentAddons.pitrDuration?.id !== undefined && (
<div className="flex items-center justify-between">
<p
className={`${
isChangingPITRDuration ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
{currentPITRDuration?.name}
{currentAddons.pitrDuration?.name}
</p>
<p
className={`${
isChangingPITRDuration ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
${(getProductPrice(currentPITRDuration).unit_amount / 100).toFixed(2)}
${(getProductPrice(currentAddons.pitrDuration).unit_amount / 100).toFixed(2)}
</p>
</div>
)}
{isChangingPITRDuration && (
<div className="flex items-center justify-between">
<p className="text-sm">{selectedPITRDuration?.name}</p>
<p className="text-sm">{selectedAddons.pitrDuration?.name}</p>
<p className="text-sm">
${(getProductPrice(selectedPITRDuration).unit_amount / 100).toFixed(2)}
${(getProductPrice(selectedAddons.pitrDuration).unit_amount / 100).toFixed(2)}
</p>
</div>
)}
{currentAddons.customDomains?.id !== undefined && (
<div className="flex items-center justify-between">
<p
className={`${
isChangingCustomDomains ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
{currentAddons.customDomains?.name}
</p>
<p
className={`${
isChangingCustomDomains ? 'text-scale-1100 line-through' : ''
} text-sm`}
>
${(getProductPrice(currentAddons.customDomains).unit_amount / 100).toFixed(2)}
</p>
</div>
)}
{isChangingCustomDomains && (
<div className="flex items-center justify-between">
<p className="text-sm">{selectedAddons.customDomains?.name}</p>
<p className="text-sm">
${(getProductPrice(selectedAddons.customDomains).unit_amount / 100).toFixed(2)}
</p>
</div>
)}
@@ -198,9 +237,9 @@ const PaymentSummaryPanel: FC<Props> = ({
defaultVisibility
icon={<IconAlertCircle strokeWidth={2} />}
title={
currentPITRDuration?.id === undefined
currentAddons.pitrDuration?.id === undefined
? 'Enabling PITR'
: selectedPITRDuration?.id === undefined
: selectedAddons.pitrDuration?.id === undefined
? 'Disabling PITR'
: 'Updating PITR duration'
}
@@ -13,6 +13,7 @@ import {
PaymentSummaryPanel,
ComputeSizeSelection,
PITRDurationSelection,
CustomDomainSelection,
StripeSubscription,
AddNewPaymentMethodModal,
} from './'
@@ -21,7 +22,11 @@ import UpdateSuccess from './UpdateSuccess'
import { PaymentMethod, SubscriptionPreview } from './Billing.types'
import { formSubscriptionUpdatePayload, getCurrentAddons } from './Billing.utils'
import { DatabaseAddon } from './AddOns/AddOns.types'
import { formatComputeSizes, formatPITROptions } from './AddOns/AddOns.utils'
import {
formatComputeSizes,
formatCustomDomainOptions,
formatPITROptions,
} from './AddOns/AddOns.utils'
import BackButton from 'components/ui/BackButton'
// Do not allow compute size changes for af-south-1
@@ -43,12 +48,14 @@ const ProUpgrade: FC<Props> = ({
}) => {
const { app, ui } = useStore()
const router = useRouter()
const isCustomDomainsEnabled = useFlag('customDomains')
const isPITRSelfServeEnabled = useFlag('pitrSelfServe')
const { addons } = products
const computeSizes = formatComputeSizes(addons)
const pitrDurationOptions = formatPITROptions(addons)
const { currentComputeSize, currentPITRDuration } = getCurrentAddons(currentSubscription, addons)
const customDomainOptions = formatCustomDomainOptions(addons)
const currentAddons = getCurrentAddons(currentSubscription, addons)
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref
@@ -67,10 +74,27 @@ const ProUpgrade: FC<Props> = ({
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
// [Joshen TODO] Ideally we just have a state to hold all the add ons selection, rather than individual
// Even better if we can just use the <Form> component to handle all of these. Mainly to reduce the amount
// of unnecessary state management on this complex page.
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(
currentAddons.computeSize
)
const [selectedPITRDuration, setSelectedPITRDuration] = useState<DatabaseAddon>(
currentAddons.pitrDuration
)
const [selectedCustomDomainOption, setSelectedCustomDomainOption] = useState<DatabaseAddon>(
currentAddons.customDomains
)
// [Joshen] Scaffolded here
const selectedAddons = {
computeSize: selectedComputeSize,
pitrDuration: selectedPITRDuration,
customDomains: selectedCustomDomainOption,
}
const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState<string>('')
const [selectedComputeSize, setSelectedComputeSize] = useState<DatabaseAddon>(currentComputeSize)
const [selectedPITRDuration, setSelectedPITRDuration] =
useState<DatabaseAddon>(currentPITRDuration)
const [subscriptionPreview, setSubscriptionPreview] = useState<SubscriptionPreview>()
const selectedTier = isSpendCapEnabled
@@ -81,7 +105,7 @@ const ProUpgrade: FC<Props> = ({
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.PRO ||
currentSubscription.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG
const isChangingComputeSize = currentComputeSize?.id !== selectedComputeSize.id
const isChangingComputeSize = currentAddons.computeSize?.id !== selectedAddons.computeSize.id
useEffect(() => {
if (!isLoadingPaymentMethods && paymentMethods && paymentMethods.length > 0) {
@@ -91,15 +115,14 @@ const ProUpgrade: FC<Props> = ({
useEffect(() => {
getSubscriptionPreview()
}, [selectedComputeSize, selectedPITRDuration, isSpendCapEnabled])
}, [selectedComputeSize, selectedPITRDuration, selectedCustomDomainOption, isSpendCapEnabled])
const getSubscriptionPreview = async () => {
if (!selectedTier) return
const payload = formSubscriptionUpdatePayload(
selectedTier,
selectedComputeSize,
selectedPITRDuration,
selectedAddons,
selectedPaymentMethodId,
projectRegion
)
@@ -119,8 +142,7 @@ const ProUpgrade: FC<Props> = ({
const onConfirmPayment = async () => {
const payload = formSubscriptionUpdatePayload(
selectedTier,
selectedComputeSize,
selectedPITRDuration,
selectedAddons,
selectedPaymentMethodId,
projectRegion
)
@@ -220,15 +242,28 @@ const ProUpgrade: FC<Props> = ({
</div>
{projectRegion !== 'af-south-1' && (
<>
{isCustomDomainsEnabled && customDomainOptions.length > 0 && (
<>
<Divider light />
<CustomDomainSelection
options={customDomainOptions}
currentOption={
isManagingProSubscription ? currentAddons.customDomains : undefined
}
selectedOption={selectedAddons.customDomains}
onSelectOption={setSelectedCustomDomainOption}
/>
</>
)}
{isPITRSelfServeEnabled && pitrDurationOptions.length > 0 && (
<>
<Divider light />
<PITRDurationSelection
pitrDurationOptions={pitrDurationOptions}
currentPitrDuration={
isManagingProSubscription ? currentPITRDuration : undefined
isManagingProSubscription ? currentAddons.pitrDuration : undefined
}
selectedPitrDuration={selectedPITRDuration}
selectedPitrDuration={selectedAddons.pitrDuration}
onSelectOption={setSelectedPITRDuration}
/>
</>
@@ -236,8 +271,8 @@ const ProUpgrade: FC<Props> = ({
<Divider light />
<ComputeSizeSelection
computeSizes={computeSizes || []}
currentComputeSize={currentComputeSize}
selectedComputeSize={selectedComputeSize}
currentComputeSize={currentAddons.computeSize}
selectedComputeSize={selectedAddons.computeSize}
onSelectOption={setSelectedComputeSize}
/>
</>
@@ -253,12 +288,10 @@ const ProUpgrade: FC<Props> = ({
isSpendCapEnabled={isSpendCapEnabled}
// Current subscription configuration based on DB
currentPlan={currentSubscription.tier}
currentComputeSize={currentComputeSize}
currentPITRDuration={currentPITRDuration}
currentAddons={currentAddons}
// Selected subscription configuration based on UI
selectedPlan={selectedTier}
selectedComputeSize={selectedComputeSize}
selectedPITRDuration={selectedPITRDuration}
selectedAddons={selectedAddons}
paymentMethods={paymentMethods}
isLoadingPaymentMethods={isLoadingPaymentMethods}
selectedPaymentMethod={selectedPaymentMethodId}
@@ -7,6 +7,7 @@ import ExitSurvey from './ExitSurvey/ExitSurvey'
import PaymentSummaryPanel from './PaymentSummaryPanel/PaymentSummaryPanel'
import ComputeSizeSelection from './AddOns/ComputeSizeSelection'
import PITRDurationSelection from './AddOns/PITRDurationSelection'
import CustomDomainSelection from './AddOns/CustomDomainSelection'
import AddNewPaymentMethodModal from './AddNewPaymentMethodModal/AddNewPaymentMethodModal'
import UpdateSuccess from './UpdateSuccess'
@@ -22,6 +23,7 @@ export {
PaymentSummaryPanel,
ComputeSizeSelection,
PITRDurationSelection,
CustomDomainSelection,
AddNewPaymentMethodModal,
UpdateSuccess,
Invoices,
@@ -11,7 +11,7 @@ import BackupsError from './BackupsError'
import BackupsEmpty from './BackupsEmpty'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import InformationBox from 'components/ui/InformationBox'
import { IconAlertCircle } from 'ui'
import { IconAlertCircle, IconClock } from 'ui'
interface Props {}
@@ -32,6 +32,7 @@ const BackupsList: FC<Props> = ({}) => {
if (tierKey === 'FREE') {
return (
<UpgradeToPro
icon={<IconClock size="large" />}
primaryText="Free Plan does not include project backups."
projectRef={projectRef}
secondaryText="Please upgrade to the Pro plan for up to 7 days of scheduled backups."
@@ -151,7 +151,7 @@ const JWTSettings: FC<Props> = ({}) => {
<div className="w-full space-y-2">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col space-y-1">
<p>Generate a new JWT secret</p>
<p className="text-sm">Generate a new JWT secret</p>
<p className="text-sm opacity-50">
A random secret will be created, or you can create your own.
</p>
@@ -0,0 +1,143 @@
import Link from 'next/link'
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, IconExternalLink } from 'ui'
import { useStore } from 'hooks'
import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation'
import { useCustomDomainActivateMutation } from 'data/custom-domains/custom-domains-activate-mutation'
import { CustomDomainResponse } from 'data/custom-domains/custom-domains-query'
import Panel from 'components/ui/Panel'
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
export type CustomDomainActivateProps = {
projectRef?: string
customDomain: CustomDomainResponse
}
const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivateProps) => {
const { ui } = useStore()
const [isActivateConfirmModalVisible, setIsActivateConfirmModalVisible] = useState(false)
const { mutateAsync: activateCustomDomain } = useCustomDomainActivateMutation()
const { mutateAsync: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation()
const onActivateCustomDomain = async () => {
if (!projectRef) {
throw new Error('Project ref is required')
}
try {
await activateCustomDomain({ projectRef })
ui.setNotification({
category: 'success',
message: `Successfully activated custom domain`,
})
setIsActivateConfirmModalVisible(false)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: error.message,
})
}
}
const onCancelCustomDomain = async () => {
if (!projectRef) {
throw new Error('Project ref is required')
}
try {
await deleteCustomDomain({ projectRef })
} catch (error: any) {
ui.setNotification({ category: 'error', message: error.message })
}
}
return (
<>
<div className="flex flex-col items-start">
<Panel.Content>
<div className="flex flex-col gap-2">
<h4 className="text-scale-1200">
Setup complete! Press activate to enable the custom domain{' '}
<code className="text-sm">{customDomain.hostname}</code> for this project.
</h4>
<span className="text-sm text-scale-1100">
We recommend that you schedule a downtime window of 20 - 30 minutes for your
application, as you will need to update any services that need to know about your
custom domain (e.g client side code or OAuth providers)
</span>
</div>
</Panel.Content>
<div className="w-full border-t border-scale-400" />
<Panel.Content className="w-full">
<div className="flex items-center justify-between">
<Link href="https://supabase.com/docs/guides/platform/custom-domains">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<div className="flex items-center space-x-2">
<Button
type="default"
onClick={onCancelCustomDomain}
loading={isDeleting}
className="self-end"
>
Cancel
</Button>
<Button
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
}
onClick={() => setIsActivateConfirmModalVisible(true)}
className="self-end"
>
Activate
</Button>
</div>
</div>
</Panel.Content>
</div>
<ConfirmModal
size="small"
visible={isActivateConfirmModalVisible}
// @ts-ignore
title={
<div>
Are you sure you want to activate the custom domain{' '}
<code className="text-sm">{customDomain.hostname}</code> for the project?
</div>
}
description="The existing Supabase subdomain will be deactivated."
buttonLabel="Activate"
buttonLoadingLabel="Activating"
onSelectCancel={() => setIsActivateConfirmModalVisible(false)}
onSelectConfirm={onActivateCustomDomain}
/>
</>
)
}
export default observer(CustomDomainActivate)
@@ -0,0 +1,104 @@
import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { IconAlertCircle } from 'ui'
import { useParams, useStore } from 'hooks'
import { useProjectSettingsQuery } from 'data/config/project-settings-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import Panel from 'components/ui/Panel'
import { FormHeader } from 'components/ui/Forms'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import CustomDomainDelete from './CustomDomainDelete'
import CustomDomainVerify from './CustomDomainVerify'
import CustomDomainActivate from './CustomDomainActivate'
import CustomDomainsConfigureHostname from './CustomDomainsConfigureHostname'
import CustomDomainsShimmerLoader from './CustomDomainsShimmerLoader'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
const CustomDomainConfig = () => {
const { ui } = useStore()
const { ref } = useParams()
const tier = ui.selectedProject?.subscription_tier
const { isLoading: isSettingsLoading, data: settings } = useProjectSettingsQuery({
projectRef: ref,
})
const {
isLoading: isCustomDomainsLoading,
isError,
isSuccess,
data,
} = useCustomDomainsQuery({ projectRef: ref })
const isLoading = isSettingsLoading || isCustomDomainsLoading
return (
<section>
<FormHeader title="Custom Domains" description="" />
{data?.status === '0_no_hostname_configured' ? (
<CustomDomainsConfigureHostname projectRef={ref} />
) : (
<Panel>
{isLoading && (
<Panel.Content className="space-y-6">
<CustomDomainsShimmerLoader />
</Panel.Content>
)}
{data?.status === '0_not_allowed' && (
<Panel.Content className="space-y-6">
<UpgradeToPro
icon={<IconAlertCircle size={18} strokeWidth={1.5} />}
primaryText="Custom domains are a Pro plan add-on"
projectRef={ref as string}
secondaryText={
tier === PRICING_TIER_PRODUCT_IDS.FREE
? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected'
: 'To configure a custom domain for your project, please enable the add-on'
}
/>
</Panel.Content>
)}
{isError && (
<Panel.Content className="space-y-6">
<div className="flex items-center justify-center space-x-2 py-8">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">
Failed to retrieve custom domain configuration. Please try again later or{' '}
<Link href={`/support/new?ref=${ref}&category=sales`}>
<a className="underline">contact support</a>
</Link>
.
</p>
</div>
</Panel.Content>
)}
{isSuccess && (
<div className="flex flex-col">
{(data.status === '2_initiated' || data.status === '3_challenge_verified') && (
<CustomDomainVerify
projectRef={ref}
customDomain={data.customDomain}
settings={settings}
/>
)}
{data.status === '4_origin_setup_completed' && (
<CustomDomainActivate projectRef={ref} customDomain={data.customDomain} />
)}
{data.status === '5_services_reconfigured' && (
<CustomDomainDelete projectRef={ref} customDomain={data.customDomain} />
)}
</div>
)}
</Panel>
)}
</section>
)
}
export default observer(CustomDomainConfig)
@@ -0,0 +1,104 @@
import Link from 'next/link'
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, IconExternalLink, IconTrash } from 'ui'
import { useStore } from 'hooks'
import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation'
import { CustomDomainResponse } from 'data/custom-domains/custom-domains-query'
import Panel from 'components/ui/Panel'
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
export type CustomDomainDeleteProps = {
projectRef?: string
customDomain: CustomDomainResponse
}
const CustomDomainDelete = ({ projectRef, customDomain }: CustomDomainDeleteProps) => {
const { ui } = useStore()
const [isDeleteConfirmModalVisible, setIsDeleteConfirmModalVisible] = useState(false)
const { mutateAsync: deleteCustomDomain } = useCustomDomainDeleteMutation()
const onDeleteCustomDomain = async () => {
if (!projectRef) {
throw new Error('Project ref is required')
}
try {
await deleteCustomDomain({ projectRef })
ui.setNotification({
category: 'success',
message: `Successfully deleted custom domain`,
})
setIsDeleteConfirmModalVisible(false)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: error.message,
})
}
}
return (
<>
<Panel.Content>
<div className="w-full space-y-2">
<p className="text-xs text-scale-1100">Active custom domain:</p>
<div className="flex items-center space-x-2">
<code className="text-lg mx-0 flex items-center space-x-2">
<div className="h-2 w-2 rounded-full bg-brand-900" />
<span>{customDomain.hostname}</span>
</code>
</div>
<p className="text-sm text-scale-1100">
Your custom domain is currently active and is serving traffic
</p>
</div>
</Panel.Content>
<div className="w-full border-t border-scale-400" />
<Panel.Content className="w-full">
<div className="flex items-center justify-between">
<Link href="https://supabase.com/docs/guides/platform/custom-domains">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<Button
type="danger"
icon={<IconTrash />}
onClick={() => setIsDeleteConfirmModalVisible(true)}
>
Delete Custom Domain
</Button>
</div>
</Panel.Content>
<ConfirmModal
danger
visible={isDeleteConfirmModalVisible}
// @ts-ignore
title={
<div>
Are you sure you want to delete the custom domain{' '}
<code className="text-sm">{customDomain.hostname}</code> for the project?
</div>
}
description="Your custom domain will be deactivated. You will need to re-verify your domain if you want to use it again."
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setIsDeleteConfirmModalVisible(false)}
onSelectConfirm={onDeleteCustomDomain}
/>
</>
)
}
export default observer(CustomDomainDelete)
@@ -0,0 +1,183 @@
import Link from 'next/link'
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, IconAlertCircle, IconExternalLink, IconHelpCircle, IconRefreshCw } from 'ui'
import { useStore } from 'hooks'
import { ProjectSettingsResponse } from 'data/config/project-settings-query'
import { CustomDomainResponse } from 'data/custom-domains/custom-domains-query'
import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation'
import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation'
import DNSRecord from './DNSRecord'
import Panel from 'components/ui/Panel'
import InformationBox from 'components/ui/InformationBox'
export type CustomDomainVerifyProps = {
projectRef?: string
customDomain: CustomDomainResponse
settings?: ProjectSettingsResponse
}
const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomainVerifyProps) => {
const { ui } = useStore()
const [isNotVerifiedYet, setIsNotVerifiedYet] = useState(false)
const { mutate: reverifyCustomDomain, isLoading: isReverifyLoading } =
useCustomDomainReverifyMutation({
onSuccess: (res) => {
if (res.status === '2_initiated') setIsNotVerifiedYet(true)
},
})
const { mutateAsync: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation()
const onReverifyCustomDomain = () => {
if (!projectRef) {
throw new Error('Project ref is required')
}
reverifyCustomDomain({ projectRef })
}
const onCancelCustomDomain = async () => {
if (!projectRef) {
throw new Error('Project ref is required')
}
try {
await deleteCustomDomain({ projectRef })
} catch (error: any) {
ui.setNotification({ category: 'error', message: error.message })
}
}
return (
<>
<Panel.Content className="space-y-6">
<div>
<h4 className="text-scale-1200 mb-2">
Successfully added your custom domain{' '}
<code className="text-sm">{customDomain.hostname}</code>
</h4>
<p className="text-sm text-scale-1100">
Set the following record(s) in your DNS provider, then click verify to confirm your
control over the domain
</p>
<p className="text-sm text-scale-1100">
Records which have been successfully verified will be removed from this list below.
</p>
<div className="mt-4 mb-2">
<InformationBox
hideCollapse
defaultVisibility
icon={
isNotVerifiedYet ? (
<IconAlertCircle className="text-scale-1100" strokeWidth={1.5} />
) : (
<IconHelpCircle className="text-scale-1100" strokeWidth={1.5} />
)
}
title={
isNotVerifiedYet
? 'Unable to verify records from DNS provider yet.'
: 'Please note that it may take up to 24 hours for the DNS records to propagate.'
}
description={
isNotVerifiedYet
? 'Do check again in a bit as it may take up to 24 hours for changes in DNS records to propagate.'
: undefined
}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex gap-4">
<div className="w-[50px]">
<p className="text-scale-1100 text-sm">Type</p>
</div>
<div className="text-sm grid gap-2 md:grid md:grid-cols-12 md:gap-x-4 input-mono flex-1">
<div className="flex flex-row space-x-2 justify-between col-span-12">
<label className="block text-scale-1100 text-sm break-all">Name</label>
</div>
</div>
<div className="text-sm grid gap-2 md:grid md:grid-cols-12 md:gap-x-4 input-mono flex-1">
<div className="flex flex-row space-x-2 justify-between col-span-12">
<label className="block text-scale-1100 text-sm break-all">Content</label>
</div>
</div>
</div>
{customDomain.verification_errors?.includes(
'custom hostname does not CNAME to this zone.'
) && (
<DNSRecord
type="CNAME"
name={customDomain.hostname}
value={settings?.autoApiService.app_config.endpoint ?? 'Loading...'}
/>
)}
{customDomain.ownership_verification && (
<DNSRecord
type={customDomain.ownership_verification.type}
name={customDomain.ownership_verification.name}
value={customDomain.ownership_verification.value}
/>
)}
{customDomain.ssl.status === 'pending_validation' && (
<DNSRecord
type="TXT"
name={customDomain.ssl.txt_name ?? 'Loading...'}
value={customDomain.ssl.txt_value ?? 'Loading...'}
/>
)}
{customDomain.ssl.status === 'pending_deployment' && (
<div className="flex items-center justify-center space-x-2 py-8">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-scale-1100">
SSL certificate is being deployed. Please wait a few minutes and try again.
</p>
</div>
)}
</div>
</Panel.Content>
<div className="border-t border-scale-400" />
<Panel.Content>
<div className="flex items-center justify-between">
<Link href="https://supabase.com/docs/guides/platform/custom-domains">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
<div className="flex items-center space-x-2">
<Button
type="default"
onClick={onCancelCustomDomain}
loading={isDeleting}
className="self-end"
>
Cancel
</Button>
<Button
icon={<IconRefreshCw />}
onClick={onReverifyCustomDomain}
loading={isReverifyLoading}
className="self-end"
>
Verify
</Button>
</div>
</div>
</Panel.Content>
</>
)
}
export default observer(CustomDomainVerify)
@@ -0,0 +1,111 @@
import Link from 'next/link'
import * as yup from 'yup'
import { Button, Form, IconExternalLink, Input } from 'ui'
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore } from 'hooks'
import {
FormActions,
FormPanel,
FormSection,
FormSectionContent,
FormSectionLabel,
} from 'components/ui/Forms'
import { useCustomDomainCreateMutation } from 'data/custom-domains/custom-domains-create-mutation'
const FORM_ID = 'custom-domains-form'
const schema = yup.object({
domain: yup.string().required('A value for your custom domain is required'),
})
export type CustomDomainsConfigureHostnameProps = {
projectRef?: string
}
const CustomDomainsConfigureHostname = ({ projectRef }: CustomDomainsConfigureHostnameProps) => {
const { ui } = useStore()
const canConfigureCustomDomain = checkPermissions(PermissionAction.UPDATE, 'projects')
const { mutateAsync: createCustomDomain } = useCustomDomainCreateMutation()
const onCreateCustomDomain = async (values: yup.InferType<typeof schema>) => {
if (!projectRef) {
throw new Error('Project ref is required')
}
try {
await createCustomDomain({
projectRef,
customDomain: values.domain,
})
} catch (error: any) {
ui.setNotification({
category: 'error',
message: error.message,
})
}
}
return (
<Form
id={FORM_ID}
initialValues={{ domain: '' }}
validationSchema={schema}
onSubmit={onCreateCustomDomain}
>
{({ isSubmitting, handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<>
<FormPanel
disabled={!canConfigureCustomDomain}
footer={
<div className="flex py-4 px-8">
<FormActions
form={FORM_ID}
isSubmitting={isSubmitting}
submitText="Add"
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!true}
helper={
!canConfigureCustomDomain ? (
"You need additional permissions to update your project's custom domain settings"
) : (
<Link href="https://supabase.com/docs/guides/platform/custom-domains">
<a target="_blank">
<Button type="default" icon={<IconExternalLink />}>
Documentation
</Button>
</a>
</Link>
)
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Add a Custom Domain</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="domain"
disabled={!canConfigureCustomDomain}
className="w-full"
type="text"
name="domain"
placeholder="subdomain.example.com"
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
)
}
export default observer(CustomDomainsConfigureHostname)
@@ -0,0 +1,15 @@
const CustomDomainsShimmerLoader = () => {
return (
<div className="grid grid-cols-12 gap-6 px-8 py-8">
<div className="col-span-12 lg:col-span-5">
<div className="h-6 w-1/3 bg-scale-1000 rounded shimmering-loader" />
</div>
<div className="col-span-12 lg:col-span-7">
<div className="h-[38px] w-full bg-scale-1000 rounded shimmering-loader" />
</div>
</div>
)
}
export default CustomDomainsShimmerLoader
@@ -0,0 +1,21 @@
import { Input } from 'ui'
export type DNSRecordProps = {
type: string
name: string
value: string
}
const DNSRecord = ({ type, name, value }: DNSRecordProps) => {
return (
<div className="flex gap-4 items-center">
<div className="w-[50px]">
<p className="font-mono text-base">{type.toUpperCase()}</p>
</div>
<Input readOnly copy disabled className="input-mono flex-1" value={name} layout="vertical" />
<Input readOnly copy disabled className="input-mono flex-1" value={value} layout="vertical" />
</div>
)
}
export default DNSRecord
@@ -5,6 +5,7 @@ import { useStore } from 'hooks'
import Panel from 'components/ui/Panel'
import DeleteProjectButton from './DeleteProjectButton'
import { FormHeader } from 'components/ui/Forms'
interface Props {}
@@ -16,7 +17,8 @@ const DeleteProjectPanel: FC<Props> = ({}) => {
return (
<section>
<Panel title={<p className="uppercase">Danger Zone</p>}>
<FormHeader title="Delete Project" description="" />
<Panel>
<Panel.Content>
<Alert
variant="danger"
@@ -146,7 +146,9 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
/>
) : (
<span>
{feature.units === 'bytes' ? formatBytes(usageValue) : ''}
{feature.units === 'bytes'
? formatBytes(usageValue)
: usageValue}
</span>
)}
</td>
@@ -37,7 +37,9 @@ const ColumnType: FC<Props> = ({
readOnly
disabled
label={showLabel ? 'Type' : ''}
layout="horizontal"
layout={showLabel ? 'horizontal' : undefined}
className="md:gap-x-0"
size="small"
value={value}
descriptionText={
showLabel
@@ -63,7 +63,9 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
</span>
{/* Project Dropdown */}
<ProjectDropdown />
{showOverUsageBadge && (
{/* [Joshen TODO] Temporarily hidden until usage endpoint is sorted out */}
{/* {showOverUsageBadge && (
<div className="ml-2">
<Link href={`/project/${ref}/settings/billing/subscription`}>
<a>
@@ -71,7 +73,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
</a>
</Link>
</div>
)}
)} */}
</>
)}
</>
@@ -366,7 +366,7 @@ curl -X POST '${endpoint}/rest/v1/${resourceId}' \\
-H "apikey: ${apiKey}" \\
-H "Authorization: Bearer ${apiKey}" \\
-H "Content-Type: application/json" \\
-H "Prefer: return=representation" \\
-H "Prefer: return=minimal" \\
-d '{ "some_column": "someValue", "other_column": "otherValue" }'
`,
},
@@ -436,7 +436,7 @@ curl -X PATCH '${endpoint}/rest/v1/${resourceId}?some_column=eq.someValue' \\
-H "apikey: ${apiKey}" \\
-H "Authorization: Bearer ${apiKey}" \\
-H "Content-Type: application/json" \\
-H "Prefer: return=representation" \\
-H "Prefer: return=minimal" \\
-d '{ "other_column": "otherValue" }'
`,
},
@@ -1,6 +1,6 @@
import useSWR from 'swr'
import { FC, useState } from 'react'
import { Button, Form, Input, Listbox } from 'ui'
import { Button, Form, IconClock, Input, Listbox } from 'ui'
import { useStore } from 'hooks'
import { patch, get } from 'lib/common/fetch'
@@ -173,6 +173,7 @@ const StorageConfig = ({ config, projectRef }: any) => {
{isFreeTier && (
<div className="px-6 pb-6">
<UpgradeToPro
icon={<IconClock size="large" />}
primaryText="Free Plan has a fixed upload file size limit of 50 MB."
projectRef={projectRef}
secondaryText="Please upgrade to Pro plan for a configurable upload file size limit of up to 5 GB."
@@ -56,13 +56,20 @@ const ConfirmModal = ({
<Modal.Separator />
<Modal.Content>
<div className="flex items-center gap-2">
<Button htmlType="button" type="default" onClick={onSelectCancel} block>
<Button
block
htmlType="button"
type="default"
onClick={onSelectCancel}
disabled={loading}
>
Cancel
</Button>
<Button
htmlType="submit"
block
type={danger ? 'danger' : 'primary'}
disabled={loading}
loading={loading}
>
{buttonLoadingLabel && loading
+3 -1
View File
@@ -7,6 +7,7 @@ interface Props {
helper?: React.ReactNode // Helper text to show alongside actions
disabled?: boolean
isSubmitting?: boolean
submitText?: string
}
const FormActions = ({
@@ -16,6 +17,7 @@ const FormActions = ({
helper,
disabled = false,
isSubmitting,
submitText = 'Save',
}: Props) => {
const isDisabled = isSubmitting || disabled || (!hasChanges && hasChanges !== undefined)
@@ -39,7 +41,7 @@ const FormActions = ({
disabled={isDisabled}
loading={isSubmitting}
>
Save
{submitText}
</Button>
</div>
</div>
+36 -25
View File
@@ -1,38 +1,49 @@
import { Button, IconClock } from 'ui'
import { Button } from 'ui'
import { observer } from 'mobx-react-lite'
import Link from 'next/link'
import { FC } from 'react'
import { FC, ReactNode } from 'react'
import { useStore } from 'hooks'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
interface Props {
icon?: ReactNode
primaryText: string
projectRef: string
secondaryText: string
}
const UpgradeToPro: FC<Props> = ({ primaryText, projectRef, secondaryText }) => (
<div
className={[
'block w-full rounded border border-opacity-20 py-4 px-6',
'border-gray-600 bg-gray-100',
'dark:border-gray-300 dark:bg-gray-400',
].join(' ')}
>
<div className="flex space-x-3">
<div className="mt-1">
<IconClock size="large" />
</div>
<div className="flex w-full items-center justify-between">
<div className="space-y-1">
<p className="text-sm">{primaryText}</p>
<div>
<p className="text-sm text-scale-1100">{secondaryText}</p>
const UpgradeToPro: FC<Props> = ({ icon, primaryText, projectRef, secondaryText }) => {
const { ui } = useStore()
const tier = ui.selectedProject?.subscription_tier
return (
<div
className={[
'block w-full rounded border border-opacity-20 py-4 px-6',
'border-gray-600 bg-gray-100',
'dark:border-gray-300 dark:bg-gray-400',
].join(' ')}
>
<div className="flex space-x-3">
{icon && <div className="mt-1">{icon}</div>}
<div className="flex w-full items-center justify-between">
<div className="space-y-1">
<p className="text-sm">{primaryText}</p>
<div>
<p className="text-sm text-scale-1100">{secondaryText}</p>
</div>
</div>
<Link href={`/project/${projectRef}/settings/billing/update`}>
<a>
<Button type="primary">
{tier === PRICING_TIER_PRODUCT_IDS.FREE ? 'Upgrade to Pro' : 'Modify subscription'}
</Button>
</a>
</Link>
</div>
<Link href={`/project/${projectRef}/settings/billing/update`}>
<Button type="primary">Upgrade to Pro</Button>
</Link>
</div>
</div>
</div>
)
)
}
export default UpgradeToPro
export default observer(UpgradeToPro)
+3
View File
@@ -0,0 +1,3 @@
export const configKeys = {
settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const,
}
@@ -0,0 +1,122 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { configKeys } from './keys'
export type ProjectSettingsVariables = {
projectRef?: string
}
export type Project = {
id: number
name: string
ref: string
status: string
db_host: string
db_name: string
db_user: string
db_schema: string
db_port: number
db_ssl: boolean
services: Service[]
}
export type App = {
id: number
name: string
}
export type AppConfig = {
endpoint: string
db_schema: string
realtime_multitenant_enabled: boolean
}
export type Service = {
id: number
name: string
app_config: AppConfig
app: App
service_api_keys: ServiceApiKey[]
}
export type ServiceApiKey = {
api_key_encrypted: string
tags: string
name: string
}
export type AutoApiService = Service & {
endpoint: string
restUrl: string
project: Project
defaultApiKey: string
serviceApiKey: string
}
export type ProjectSettingsResponse = {
project: Project
autoApiService: AutoApiService
}
export async function getProjectSettings(
{ projectRef }: ProjectSettingsVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await get(`${API_URL}/props/project/${projectRef}/api`, {
signal,
})
if (response.error) {
throw response.error
}
return response as ProjectSettingsResponse
}
export type ProjectSettingsData = Awaited<ReturnType<typeof getProjectSettings>>
export type ProjectSettingsError = unknown
export const useProjectSettingsQuery = <TData = ProjectSettingsData>(
{ projectRef }: ProjectSettingsVariables,
{
enabled = true,
...options
}: UseQueryOptions<ProjectSettingsData, ProjectSettingsError, TData> = {}
) =>
useQuery<ProjectSettingsData, ProjectSettingsError, TData>(
configKeys.settings(projectRef),
({ signal }) => getProjectSettings({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
refetchInterval(data, query) {
if (!data) {
return false
}
const { autoApiService } = data as unknown as ProjectSettingsData
const apiKeys = autoApiService?.service_api_keys ?? []
const interval = apiKeys.length === 0 ? 2000 : 0
return interval
},
...options,
}
)
export const useProjectSettingsPrefetch = ({ projectRef }: ProjectSettingsVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(configKeys.settings(projectRef), ({ signal }) =>
getProjectSettings({ projectRef }, signal)
)
}
}, [projectRef])
}
@@ -0,0 +1,49 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { post } from 'lib/common/fetch'
import { customDomainKeys } from './keys'
export type CustomDomainActivateVariables = {
projectRef: string
}
export async function activateCustomDomain({ projectRef }: CustomDomainActivateVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await post(
`${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/custom-hostname/activate`,
{}
)
if (response.error) {
throw response.error
}
return response
}
type CustomDomainActivateData = Awaited<ReturnType<typeof activateCustomDomain>>
export const useCustomDomainActivateMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<CustomDomainActivateData, unknown, CustomDomainActivateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CustomDomainActivateData, unknown, CustomDomainActivateVariables>(
(vars) => activateCustomDomain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(customDomainKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}
@@ -0,0 +1,55 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { post } from 'lib/common/fetch'
import { customDomainKeys } from './keys'
export type CustomDomainCreateVariables = {
projectRef: string
customDomain: string
}
export async function createCustomDomain({
projectRef,
customDomain,
}: CustomDomainCreateVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await post(
`${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/custom-hostname/initialize`,
{
custom_hostname: customDomain,
}
)
if (response.error) {
throw response.error
}
return response
}
type CustomDomainCreateData = Awaited<ReturnType<typeof createCustomDomain>>
export const useCustomDomainCreateMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<CustomDomainCreateData, unknown, CustomDomainCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CustomDomainCreateData, unknown, CustomDomainCreateVariables>(
(vars) => createCustomDomain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(customDomainKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}
@@ -0,0 +1,58 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { delete_ } from 'lib/common/fetch'
import { getCustomDomains } from './custom-domains-query'
import { customDomainKeys } from './keys'
export type CustomDomainDeleteVariables = {
projectRef: string
}
export async function deleteCustomDomain({ projectRef }: CustomDomainDeleteVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await delete_(
`${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/custom-hostname`,
{}
)
if (response.error) {
throw response.error
}
return response
}
type CustomDomainDeleteData = Awaited<ReturnType<typeof deleteCustomDomain>>
export const useCustomDomainDeleteMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<CustomDomainDeleteData, unknown, CustomDomainDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CustomDomainDeleteData, unknown, CustomDomainDeleteVariables>(
(vars) => deleteCustomDomain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
// we manually setQueriesData here instead of using
// the standard invalidateQueries is the custom domains
// endpoint doesn't immediately return the new state
queryClient.setQueriesData(customDomainKeys.list(projectRef), () => {
return {
customDomain: null,
status: '0_no_hostname_configured',
}
})
await onSuccess?.(data, variables, context)
},
...options,
}
)
}
@@ -0,0 +1,147 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { useCallback } from 'react'
import { customDomainKeys } from './keys'
export type CustomDomainsVariables = {
projectRef?: string
}
type CustomDomainStatus =
| '0_not_allowed'
| '0_no_hostname_configured'
| '1_not_started'
| '2_initiated'
| '3_challenge_verified'
| '4_origin_setup_completed'
| '5_services_reconfigured'
type Settings = {
http2: string
tls_1_3: string
min_tls_version: string
}
type Ssl = {
id: string
type: string
method: string
status: string
txt_name?: string
txt_value?: string
settings: Settings
wildcard: boolean
bundle_method: string
certificate_authority: string
validation_records?: {
status: string
txt_name: string
txt_value: string
}[]
}
type OwnershipVerification = {
name: string
type: string
value: string
}
type OwnershipVerificationHttp = {
http_url: string
http_body: string
}
export type CustomDomainResponse = {
id: string
ssl: Ssl
status: string
hostname: string
created_at: string
custom_metadata: any
verification_errors?: string[]
custom_origin_server: string
ownership_verification?: OwnershipVerification
ownership_verification_http?: OwnershipVerificationHttp
}
export async function getCustomDomains(
{ projectRef }: CustomDomainsVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = (await get(
`${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/custom-hostname`,
{
signal,
}
)) as {
data: {
errors: any[]
result: CustomDomainResponse
success: boolean
messages: any[]
}
status: CustomDomainStatus
error: unknown
}
if (response.error) {
// not allowed error and no hostname configured error are
// valid steps in the process of setting up a custom domain
// so we convert them to data instead of errors
const isNotAllowedError =
(response.error as any)?.code === 400 &&
(response.error as any)?.message?.includes('not allowed to set up custom domain')
if (isNotAllowedError) {
return {
customDomain: null,
status: '0_not_allowed',
} as const
}
const isNoHostnameConfiguredError =
(response.error as any)?.code === 400 &&
(response.error as any)?.message?.includes('custom hostname configuration')
if (isNoHostnameConfiguredError) {
return {
customDomain: null,
status: '0_no_hostname_configured',
} as const
}
throw response.error
}
return { customDomain: response?.data?.result, status: response.status }
}
export type CustomDomainsData = Awaited<ReturnType<typeof getCustomDomains>>
export type CustomDomainsError = unknown
export const useCustomDomainsQuery = <TData = CustomDomainsData>(
{ projectRef }: CustomDomainsVariables,
{ enabled = true, ...options }: UseQueryOptions<CustomDomainsData, CustomDomainsError, TData> = {}
) =>
useQuery<CustomDomainsData, CustomDomainsError, TData>(
customDomainKeys.list(projectRef),
({ signal }) => getCustomDomains({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
export const useCustomDomainsPrefetch = ({ projectRef }: CustomDomainsVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(customDomainKeys.list(projectRef), ({ signal }) =>
getCustomDomains({ projectRef }, signal)
)
}
}, [projectRef])
}
@@ -0,0 +1,49 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { post } from 'lib/common/fetch'
import { customDomainKeys } from './keys'
export type CustomDomainReverifyVariables = {
projectRef: string
}
export async function reverifyCustomDomain({ projectRef }: CustomDomainReverifyVariables) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const response = await post(
`${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/custom-hostname/reverify`,
{}
)
if (response.error) {
throw response.error
}
return response
}
type CustomDomainReverifyData = Awaited<ReturnType<typeof reverifyCustomDomain>>
export const useCustomDomainReverifyMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<CustomDomainReverifyData, unknown, CustomDomainReverifyVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CustomDomainReverifyData, unknown, CustomDomainReverifyVariables>(
(vars) => reverifyCustomDomain(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(customDomainKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}
+3
View File
@@ -0,0 +1,3 @@
export const customDomainKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'custom-domains'] as const,
}
+21
View File
@@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query'
import { useState } from 'react'
let queryClient: QueryClient | undefined
export function getQueryClient() {
const _queryClient = queryClient ?? new QueryClient()
// For SSG and SSR always create a new queryClient
if (typeof window === 'undefined') return _queryClient
// Create the queryClient once in the client
if (!queryClient) queryClient = _queryClient
return queryClient
}
export function useRootQueryClient() {
const [_queryClient] = useState(() => getQueryClient())
return _queryClient
}
+7
View File
@@ -144,6 +144,13 @@ export const METRICS = [
// provider: 'daily-stats',
// category: METRIC_CATEGORIES.API_AUTH,
// },
{
key: 'total_auth_billing_period_mau',
label: 'Auth Monthly Active User',
provider: 'daily-stats',
category: METRIC_CATEGORIES.API_AUTH,
},
{
key: 'total_auth_ingress',
label: 'Auth Ingress',
+4 -2
View File
@@ -28,7 +28,9 @@
"@supabase/postgres-meta": "^0.26.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.29",
"@supabase/supabase-js": "^2.1.0",
"@supabase/supabase-js": "^2.1.1",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
@@ -132,6 +134,6 @@
"storybook-dark-mode": "^1.0.4",
"tailwindcss": "^3.0.22",
"ts-jest": "^27.0.7",
"typescript": "4.3.4"
"typescript": "4.8.4"
}
}
+26 -15
View File
@@ -24,11 +24,14 @@ import Head from 'next/head'
import { AppPropsWithLayout } from 'types'
import { useEffect, useState } from 'react'
import { Hydrate, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RootStore } from 'stores'
import { StoreProvider } from 'hooks'
import { GOTRUE_ERRORS } from 'lib/constants'
import { auth } from 'lib/gotrue'
import { dart } from 'lib/constants/prism'
import { useRootQueryClient } from 'data/query-client'
import { PortalToast, RouteValidationWrapper, AppBannerWrapper } from 'components/interfaces/App'
import PageTelemetry from 'components/ui/PageTelemetry'
@@ -42,6 +45,7 @@ dayjs.extend(timezone)
dart(Prism)
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const queryClient = useRootQueryClient()
const [rootStore] = useState(() => new RootStore())
useEffect(() => {
@@ -96,21 +100,28 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page)
return (
<StoreProvider rootStore={rootStore}>
<FlagProvider>
<Head>
<title>Supabase</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<link rel="stylesheet" type="text/css" href="/css/fonts.css" />
</Head>
<PageTelemetry>
<RouteValidationWrapper>
<AppBannerWrapper>{getLayout(<Component {...pageProps} />)}</AppBannerWrapper>
</RouteValidationWrapper>
</PageTelemetry>
<PortalToast />
</FlagProvider>
</StoreProvider>
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<StoreProvider rootStore={rootStore}>
<FlagProvider>
<Head>
<title>Supabase</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<link rel="stylesheet" type="text/css" href="/css/fonts.css" />
</Head>
<PageTelemetry>
<RouteValidationWrapper>
<AppBannerWrapper>{getLayout(<Component {...pageProps} />)}</AppBannerWrapper>
</RouteValidationWrapper>
</PageTelemetry>
<PortalToast />
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</FlagProvider>
</StoreProvider>
</Hydrate>
</QueryClientProvider>
)
}
export default MyApp
@@ -13,6 +13,7 @@ import InformationBox from 'components/ui/InformationBox'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR'
import BackupsError from 'components/interfaces/Database/Backups/BackupsError'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
const DatabasePhysicalBackups: NextPageWithLayout = () => {
const { ui } = useStore()
@@ -51,6 +52,7 @@ const PITR = () => {
const { configuration, error, isLoading } = backups
const ref = ui.selectedProject?.ref ?? 'default'
const tier = ui.selectedProject?.subscription_tier
const isEnabled = configuration.walg_enabled
const isPITRSelfServeEnabled = useFlag('pitrSelfServe')
@@ -63,8 +65,12 @@ const PITR = () => {
return isPITRSelfServeEnabled ? (
<UpgradeToPro
projectRef={ref}
primaryText="Free Plan does not include point in time recovery."
secondaryText="Please upgrade to the Pro plan to enable point in time recovery for your project."
primaryText="Point in time recovery is a Pro plan add-on."
secondaryText={
tier === PRICING_TIER_PRODUCT_IDS.FREE
? 'Please upgrade to the Pro plan with the PITR add-on selected to enable point in time recovery for your project.'
: 'Please enable the add-on to enable point in time recovery for your project.'
}
/>
) : (
<InformationBox
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more