mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
105 files changed
+5256
-889
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+292
-54
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+143
@@ -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)
|
||||
+104
@@ -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)
|
||||
+104
@@ -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)
|
||||
+183
@@ -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)
|
||||
+111
@@ -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)
|
||||
+15
@@ -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
|
||||
+3
-1
@@ -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>
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const customDomainKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'custom-domains'] as const,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user