Merge branch 'master' into feat/auth-config-refactor
No files matched your search
@@ -14,6 +14,8 @@
|
||||
- [Shared components](#shared-components)
|
||||
- [Installing packages](#installing-packages)
|
||||
- [Development](#development)
|
||||
- [Common Tasks](#common-tasks)
|
||||
- [Adding redirects](#adding-redirects)
|
||||
- [Finally](#finally)
|
||||
- [Community Channels](#community-channels)
|
||||
|
||||
@@ -211,6 +213,12 @@ You do not need to install `devDependencies` in each workspace. These can all be
|
||||
|
||||
`npm run dev`
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding Redirects
|
||||
|
||||
To add a redirect, simple create a new entry in the [`next.config.js`](https://github.com/supabase/supabase/blob/master/apps/www/next.config.js) file in our main site.
|
||||
|
||||
## Finally
|
||||
|
||||
After making your changes to the file(s) you'd like to update, it's time to open a pull request. Once you submit your pull request, others from the Supabase team/community will review it with you.
|
||||
|
||||
@@ -1032,6 +1032,7 @@ h4.method-list-item-label {
|
||||
h4.method-list-item-label .method-list-item-label-name {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.8rem;
|
||||
margin-right: 4px;
|
||||
padding: var(--ifm-code-padding-vertical) 0;
|
||||
}
|
||||
h4.method-list-item-label .method-list-item-label-badge {
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function AuthProviders() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{authProviders.map((x: any) => (
|
||||
<div key={x.name}>
|
||||
<Link href={x.href}>
|
||||
<Link passHref href={x.href}>
|
||||
<Card>
|
||||
<div className="flex justify-between gap-10">
|
||||
<p>{x.name}</p>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.code-block::before {
|
||||
content: '';
|
||||
background: #1e1e1e;
|
||||
height: 10px;
|
||||
width: 48px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-block::after {
|
||||
content: '';
|
||||
background: #1e1e1e;
|
||||
height: 12px;
|
||||
width: 48px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-block code .linenumber {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.code-block code::after,
|
||||
.code-block code::before {
|
||||
content: none !important;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import monokaiCustomTheme from './CodeBlock.utils'
|
||||
import CodeBlockStyles from './CodeBlock.module.css'
|
||||
import { Button, IconCopy } from '@supabase/ui'
|
||||
import { Button, IconCheck, IconCopy } from '@supabase/ui'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
|
||||
import js from 'react-syntax-highlighter/dist/cjs/languages/hljs/javascript'
|
||||
import py from 'react-syntax-highlighter/dist/cjs/languages/hljs/python'
|
||||
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
lang: 'js' | 'sql' | 'py'
|
||||
@@ -18,6 +18,15 @@ interface Props {
|
||||
}
|
||||
|
||||
function CodeBlock(props: Props) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
let lang = props.lang
|
||||
? props.lang
|
||||
: props.className
|
||||
@@ -38,7 +47,7 @@ function CodeBlock(props: Props) {
|
||||
<SyntaxHighlighter
|
||||
language={lang}
|
||||
style={monokaiCustomTheme}
|
||||
className={'code-block dark:border-dark rounded-lg border border-gray-600'}
|
||||
className={'code-block border-scale-600 rounded-lg border'}
|
||||
customStyle={{
|
||||
padding: 0,
|
||||
fontSize: large ? 18 : 12,
|
||||
@@ -51,23 +60,29 @@ function CodeBlock(props: Props) {
|
||||
paddingTop: '128px',
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
minWidth: '48px',
|
||||
minWidth: '44px',
|
||||
background: '#1e1e1e',
|
||||
paddingLeft: '21px',
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '4px',
|
||||
marginRight: '12px',
|
||||
color: '#828282',
|
||||
textAlign: 'center',
|
||||
fontSize: large ? 14 : 12,
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
{props.children?.trimEnd()}
|
||||
</SyntaxHighlighter>
|
||||
{!props.hideCopy && props.children ? (
|
||||
<div className="dark absolute right-2 top-2">
|
||||
<CopyToClipboard text={props.children}>
|
||||
<Button type="outline" className="dark:bg-dark-800" icon={<IconCopy />}>
|
||||
Copy
|
||||
<Button
|
||||
type="default"
|
||||
icon={copied ? <IconCheck /> : <IconCopy />}
|
||||
onClick={() => handleCopy()}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Typography } from '@supabase/ui'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import footer from '../data/footer.json'
|
||||
import { useTheme } from './Providers'
|
||||
@@ -16,24 +16,35 @@ const Footer = () => {
|
||||
</h2>
|
||||
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
<div className="space-y-8 xl:col-span-1">
|
||||
<Link href="#">
|
||||
<Link href="/">
|
||||
<a>
|
||||
<img
|
||||
className="w-40"
|
||||
<Image
|
||||
src={isDarkMode ? `/supabase-dark.svg` : `/supabase-light.svg`}
|
||||
alt="Supabase"
|
||||
width={160}
|
||||
height={22}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex space-x-5">
|
||||
<a href="https://twitter.com/supabase" className="text-scale-900 hover:text-scale-1100">
|
||||
<a
|
||||
href="https://twitter.com/supabase"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-scale-900 hover:text-scale-1100"
|
||||
>
|
||||
<span className="sr-only">Twitter</span>
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/supabase" className="text-scale-900 hover:text-scale-1100">
|
||||
<a
|
||||
href="https://github.com/supabase"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-scale-900 hover:text-scale-1100"
|
||||
>
|
||||
<span className="sr-only">GitHub</span>
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
@@ -46,6 +57,8 @@ const Footer = () => {
|
||||
|
||||
<a
|
||||
href="https://discord.supabase.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-scale-900 hover:text-scale-1100"
|
||||
>
|
||||
<span className="sr-only">Discord</span>
|
||||
@@ -60,6 +73,8 @@ const Footer = () => {
|
||||
|
||||
<a
|
||||
href="https://youtube.com/c/supabase"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-scale-900 hover:text-scale-1100"
|
||||
>
|
||||
<span className="sr-only">Youtube</span>
|
||||
@@ -78,21 +93,22 @@ const Footer = () => {
|
||||
<ul className="mt-4 space-y-2">
|
||||
{segment.links.map((link: any, idx: number) => (
|
||||
<li key={`${segment.title}_link_${idx}`}>
|
||||
<a
|
||||
href={link.url}
|
||||
className={`text-sm ${
|
||||
link.url
|
||||
? 'text-scale-1000 dark:text-scale-1000'
|
||||
: 'text-scale-300 dark:text-scale-300'
|
||||
} hover:text-scale-900 dark:hover:text-scale-500`}
|
||||
>
|
||||
{link.text}
|
||||
{!link.url && (
|
||||
<span className="dark:text-dark-300 block text-sm text-gray-300">
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<Link href={link.url}>
|
||||
<a
|
||||
className={`text-sm ${
|
||||
link.url
|
||||
? 'text-scale-1000 dark:text-scale-1000'
|
||||
: 'text-scale-300 dark:text-scale-300'
|
||||
} hover:text-brand-900 dark:hover:text-brand-1100 transition`}
|
||||
>
|
||||
{link.text}
|
||||
{!link.url && (
|
||||
<span className="dark:text-dark-300 block text-sm text-gray-300">
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
export default function LinkCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
link,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: ReactElement
|
||||
link: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border-scale-200 hover:border-brand-900 dark:bg-scale-400 m-4 w-full cursor-pointer rounded-md border px-4 shadow-sm hover:shadow-2xl lg:w-2/5">
|
||||
{description ? (
|
||||
<>
|
||||
<h4 className="my-0 p-0">{title}</h4>
|
||||
<p>{description}</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex">
|
||||
{icon}
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href={link || '#'}>
|
||||
<a className="border-scale-200 hover:border-brand-900 dark:bg-scale-400 m-4 w-full cursor-pointer rounded-md border p-4 shadow-sm hover:shadow-2xl lg:w-2/5">
|
||||
{description ? (
|
||||
<>
|
||||
<h4 className="m-0 mb-4 p-0 text-base font-semibold">{title}</h4>
|
||||
<p className="text-scale-1100 m-0 p-0">{description}</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex">
|
||||
{icon}
|
||||
<p className="text-scale-1200 m-0 p-0">{title}</p>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import Image from 'next/image'
|
||||
export default function Sponsor({ imageUrl, handle }: { imageUrl: string; handle: string }) {
|
||||
return (
|
||||
<div className="my-4 flex h-14 w-44 flex-row items-center">
|
||||
<img
|
||||
<Image
|
||||
className="mr-2 rounded-full"
|
||||
src={`https://github.com/${handle}.png`}
|
||||
className="mr-2 w-10 rounded-full"
|
||||
alt={handle}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<h5>{handle}</h5>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ReactElement } from 'react'
|
||||
import { Tabs } from '@supabase/ui'
|
||||
export default function TabPanel(props) {
|
||||
console.log({ props })
|
||||
export default function TabPanel(props: { id: string; label: string | undefined; children: any }) {
|
||||
return (
|
||||
<Tabs.Panel id={props.id} label={props.label}>
|
||||
{props.children}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Tabs } from '@supabase/ui'
|
||||
|
||||
const TabWrapper = (props) => {
|
||||
console.log('Tab props: ', props)
|
||||
const { children, ...otherProps } = props
|
||||
|
||||
return <Tabs {...otherProps}>{props.children}</Tabs>
|
||||
const TabWrapper = (props: any) => {
|
||||
return (
|
||||
<div className="dashboard-tabs">
|
||||
<Tabs size="xlarge" type="underlined">
|
||||
<Tabs.Panel id={props.id} label={props.label}>
|
||||
{props.children}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabWrapper
|
||||
@@ -13,9 +13,12 @@ function DarkModeToggle() {
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="flex w-full justify-between" onClick={() => toggleDarkMode()}>
|
||||
{isDarkMode ? <span>Dark Mode</span> : <span>Light Mode</span>}
|
||||
{isDarkMode ? <IconMoon /> : <IconSun />}
|
||||
<button onClick={() => toggleDarkMode()}>
|
||||
{isDarkMode ? (
|
||||
<IconMoon className="text-scale-1100 stroke-2" />
|
||||
) : (
|
||||
<IconSun className="text-scale-1100 stroke-2" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import { ReactElement } from 'react'
|
||||
import Head from 'next/head'
|
||||
import NavBar from '../nav/NavBar'
|
||||
import SideBar from '../nav/SideBar'
|
||||
@@ -12,8 +12,8 @@ const DocsLayout = ({
|
||||
currentPage,
|
||||
}: {
|
||||
meta: { title: string; description: string }
|
||||
children: string
|
||||
toc: any
|
||||
children: ReactElement
|
||||
toc?: any
|
||||
menuItems: any
|
||||
currentPage: string
|
||||
}) => {
|
||||
@@ -25,7 +25,7 @@ const DocsLayout = ({
|
||||
<title>{meta?.title} | Supabase</title>
|
||||
<meta name="description" content={meta?.description} />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<link rel="icon" href="/docs/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={meta?.title} />
|
||||
<meta property="og:description" content={meta?.description} />
|
||||
@@ -40,24 +40,28 @@ const DocsLayout = ({
|
||||
|
||||
<main>
|
||||
<NavBar currentPage={currentPage} />
|
||||
<div className="flex flex-row">
|
||||
<div className="flex w-full flex-row">
|
||||
<SideBar menuItems={menuItems} />
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-12">
|
||||
<article className="prose dark:prose-dark dark:bg-scale-200 col-span-8 max-w-none p-8">
|
||||
{children}
|
||||
</article>
|
||||
<div className="prose dark:bg-scale-200 col-span-4 max-w-none p-8">
|
||||
<h5>On this page</h5>
|
||||
{toc
|
||||
? toc.json.map((item: any) => {
|
||||
<div className="docs-width flex w-full grid-cols-12 justify-between p-4 pb-8">
|
||||
<div className="w-full p-4">
|
||||
<article className="prose dark:prose-dark dark:bg-scale-200 width-full">
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="prose border-scale-400 dark:bg-scale-200 thin-scrollbar table-of-contents-height w-1/4 border-l px-4">
|
||||
<h5>On this page</h5>
|
||||
<ul className="list-none pl-2 text-[0.8rem]">
|
||||
{toc.json.map((item: any, i: number) => {
|
||||
return (
|
||||
<li>
|
||||
<li key={i}>
|
||||
<a href={`#${item.slug}`}>{item.content}</a>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
: 'Please note: No TOC on this page'}
|
||||
</div>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
} from '@supabase/ui'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import styles from '../../styles/Home.module.css'
|
||||
import { useTheme } from '../Providers'
|
||||
import ThemeToggle from '../ThemeToggle'
|
||||
|
||||
const NavBar = ({ currentPage }: { currentPage: string }) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -32,16 +32,14 @@ const NavBar = ({ currentPage }: { currentPage: string }) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`dark:border-scale-400 dark:bg-scale-200 sticky top-0 z-10 flex h-[72px] justify-between border-b bg-white p-4 backdrop-blur backdrop-filter`}
|
||||
>
|
||||
<nav className="dark:border-scale-400 dark:bg-scale-200 sticky top-0 z-10 flex h-[72px] items-center justify-between border-b bg-white p-4 backdrop-blur backdrop-filter">
|
||||
<div className="flex items-center">
|
||||
<button className="mr-2 block lg:hidden">
|
||||
<IconMenu />
|
||||
<button className="mr-4 block stroke-2 lg:hidden">
|
||||
<IconMenu className="text-scale-1100" />
|
||||
</button>
|
||||
{mounted && (
|
||||
<Link href="/">
|
||||
<a>
|
||||
<a className="flex items-center">
|
||||
<Image
|
||||
className="cursor-pointer"
|
||||
src={isDarkMode ? `/supabase-dark.svg` : `/supabase-light.svg`}
|
||||
@@ -53,7 +51,7 @@ const NavBar = ({ currentPage }: { currentPage: string }) => {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<ul className={`${styles.navLinks} flex`}>
|
||||
<ul className="ml-8 hidden space-x-8 lg:flex">
|
||||
{pageLinks.map((p) => (
|
||||
<li key={`${p.text}-${p.link}`}>
|
||||
<Link href={p.link}>
|
||||
@@ -70,17 +68,20 @@ const NavBar = ({ currentPage }: { currentPage: string }) => {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${styles.navRight} flex items-center`}>
|
||||
<Link href="https://github.com/supabase/supabase">
|
||||
<a>
|
||||
<IconGitHub className="text-scale-1100" />
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/supabase">
|
||||
<a>
|
||||
<IconTwitter className="text-scale-1100" />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="hidden items-center space-x-6 md:flex">
|
||||
<Link href="https://github.com/supabase/supabase">
|
||||
<a target="_blank">
|
||||
<IconGitHub className="text-scale-1100 stroke-2" />
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/supabase">
|
||||
<a target="_blank">
|
||||
<IconTwitter className="text-scale-1100 stroke-2" />
|
||||
</a>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
icon={<IconSearch />}
|
||||
|
||||
@@ -1,60 +1,55 @@
|
||||
import { Menu } from '@supabase/ui'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import styles from '../../styles/Home.module.css'
|
||||
import ThemeToggle from '../ThemeToggle'
|
||||
|
||||
const SideBar = ({ menuItems }: { menuItems: any }) => {
|
||||
const { asPath } = useRouter()
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebar} dark:bg-scale-200 dark:border-scale-400 relative h-full border-r py-4`}
|
||||
>
|
||||
<Menu>
|
||||
<div className="dark:bg-scale-200 dark:border-scale-400 thin-scrollbar sidebar-width sticky top-0 flex h-screen overflow-y-scroll border-r py-4">
|
||||
<Menu className="w-full">
|
||||
<div>
|
||||
{Object.keys(menuItems).map((key) => {
|
||||
return (
|
||||
<div key={key} className="mb-4">
|
||||
<Menu.Group title={key} />
|
||||
{menuItems[key].map((item: { link: string; text: string; sections: Array }) => (
|
||||
<span key={item.link}>
|
||||
{Array.isArray(item.sections) ? (
|
||||
<>
|
||||
<Menu.Group title={item.text} />
|
||||
{item.sections.map((section) => (
|
||||
<Link href={section.link} key={`${item.text}-${section.text}`}>
|
||||
<Menu.Item
|
||||
active={asPath == section.link}
|
||||
showActiveBar={asPath === section.link}
|
||||
>
|
||||
{section.text}
|
||||
{menuItems[key].map(
|
||||
(item: { link: string; text: string; sections: Array<any> }) => (
|
||||
<span key={item.link}>
|
||||
{Array.isArray(item.sections) ? (
|
||||
<>
|
||||
<Menu.Group title={item.text} />
|
||||
{item.sections.map((section) => (
|
||||
<Link href={section.link} key={`${item.text}-${section.text}`}>
|
||||
<a>
|
||||
<Menu.Item active={asPath == section.link}>
|
||||
{section.text}
|
||||
</Menu.Item>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Link href={item.link}>
|
||||
<a>
|
||||
<Menu.Item active={asPath === item.link}>
|
||||
<div
|
||||
className={
|
||||
asPath === item.link ? 'text-brand-900' : 'text-scale-1000'
|
||||
}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Link href={item.link} passHref>
|
||||
<a>
|
||||
<Menu.Item
|
||||
active={asPath === item.link}
|
||||
showActiveBar={asPath === item.link}
|
||||
>
|
||||
{item.text}
|
||||
</Menu.Item>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-16 w-full">
|
||||
<Menu.Item>
|
||||
<ThemeToggle />
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"links": [
|
||||
{
|
||||
"text": "Blog",
|
||||
"url": "https://supabase.com/blog"
|
||||
"url": "/blog"
|
||||
},
|
||||
{
|
||||
"text": "Open Source",
|
||||
@@ -25,7 +25,7 @@
|
||||
"links": [
|
||||
{
|
||||
"text": "Brand Assets",
|
||||
"url": "https://supabase.com/brand-assets"
|
||||
"url": "/brand-assets"
|
||||
},
|
||||
{
|
||||
"text": "Docs",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
{
|
||||
"text": "Pricing",
|
||||
"url": "https://supabase.com/pricing"
|
||||
"url": "/pricing"
|
||||
},
|
||||
{
|
||||
"text": "Support",
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
{
|
||||
"text": "RSS",
|
||||
"url": "https://supabase.com/rss.xml"
|
||||
"url": "/rss.xml"
|
||||
},
|
||||
{
|
||||
"text": "Discord",
|
||||
|
||||
@@ -54,7 +54,7 @@ or you can just copy/paste the SQL from below and run it yourself.
|
||||
<TabsPanel id="UI" label="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ defaultActiveId="UI"
|
||||
<TabsPanel id="UI" label="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -65,7 +65,7 @@ or you can just copy/paste the SQL from below and run it yourself.
|
||||
<TabsPanel id="UI" label="Dashboard UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -62,7 +62,7 @@ or you can just copy/paste the SQL from below and run it yourself.
|
||||
<TabsPanel id="UI" label="Dashboard UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -114,7 +114,7 @@ defaultActiveId="UI"
|
||||
<TabsPanel id="UI" label="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
4. Click "Run".
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ defaultActiveId="UI"
|
||||
<TabsPanel id="UI" label="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ defaultActiveId="UI"
|
||||
<TabsPanel id="UI" label="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
1. Go to the "SQL Editor" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
@@ -194,7 +194,7 @@ const { user, session, error } = await supabase.auth.signIn({
|
||||
### Sign in with redirect.
|
||||
|
||||
Sometimes you want to control where the user is redirected to after they are logged in. Supabase supports this for
|
||||
any URL path on your website (the base domain must be the same as the domain in your Auth settings).
|
||||
any URL path on your website (the URL must either be on the same domain as your Site URL [see Auth>Settings in dashboard], or must match one of the Additional Redirect URLs [also in Auth>Settings]).
|
||||
|
||||
<Tabs
|
||||
defaultActiveId="js"
|
||||
|
||||
@@ -194,7 +194,7 @@ const { user, session, error } = await supabase.auth.signIn({
|
||||
### Sign in with redirect.
|
||||
|
||||
Sometimes you want to control where the user is redirected to after they are logged in. Supabase supports this for
|
||||
any URL path on your website (the base domain must be the same as the domain in your Auth settings).
|
||||
any URL path on your website (the URL must either be on the same domain as your Site URL [see Auth>Settings in dashboard], or must match one of the Additional Redirect URLs [also in Auth>Settings]).
|
||||
|
||||
<Tabs
|
||||
defaultActiveId="js"
|
||||
|
||||
@@ -32,14 +32,14 @@ function walk(dir: string) {
|
||||
const list = fs.readdirSync(dir)
|
||||
list.forEach(function (file) {
|
||||
file = dir + '/' + file
|
||||
const stat = fs.statSync(file)
|
||||
if (stat && stat.isDirectory()) {
|
||||
/* Recurse into a subdirectory */
|
||||
results = results.concat(walk(file))
|
||||
} else {
|
||||
/* Is a file */
|
||||
results.push(file)
|
||||
}
|
||||
let slugs = []
|
||||
|
||||
fs.readdirSync(dir).forEach((file) => {
|
||||
let absolute = join(dir, file)
|
||||
if (fs.statSync(absolute).isDirectory()) {
|
||||
fs.readdirSync(absolute).forEach((subFile) => slugs.push(file.concat('/' + subFile)))
|
||||
}
|
||||
})
|
||||
})
|
||||
return results
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactElement } from 'react'
|
||||
import { getAllDocs, getDocsBySlug } from '../lib/docs'
|
||||
import Layout from '../components/layouts/Layout'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
@@ -17,7 +16,7 @@ export default function Doc({
|
||||
toc,
|
||||
}: {
|
||||
meta: { title: string; description: string }
|
||||
content: ReactElement
|
||||
content: any
|
||||
toc: any
|
||||
}) {
|
||||
const { asPath } = useRouter()
|
||||
@@ -37,6 +36,7 @@ export default function Doc({
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Layout meta={meta} toc={toc} menuItems={menuItems[page]} currentPage={page}>
|
||||
<MDXProvider components={components}>
|
||||
<MDXRemote {...content} components={components} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import type { AppProps } from 'next/app'
|
||||
import { ThemeProvider } from '../components/Providers'
|
||||
import '../styles/tailwind.css'
|
||||
import '../styles/main.css'
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
|
||||
@@ -124,18 +124,3 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: 60px;
|
||||
}
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.navLinks li {
|
||||
margin-left: 2.5em;
|
||||
}
|
||||
.navRight > * {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--sidebar-width: 300px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-[#1c1c1c] !important;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #6ee7b7;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
margin-bottom: 3rem !important;
|
||||
font-size: 3rem !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-width {
|
||||
width: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.docs-width {
|
||||
max-width: calc(100% - var(--sidebar-width));
|
||||
}
|
||||
|
||||
.table-of-contents-height {
|
||||
max-height: calc(100vh - 3.75rem - 2rem);
|
||||
overflow-y: auto;
|
||||
position: sticky;
|
||||
top: calc(3.75rem + 1rem);
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100% !important;
|
||||
max-width: max-content !important;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-[#1c1c1c] !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #6ee7b7;
|
||||
color: #333 !important;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Supabase vs Firebase
|
||||
description: Supabase is an Open Source Firebase Alternative with a SQL based Database, Auth, and Cloud Functions
|
||||
author: ant_wilson
|
||||
image: partner-gallery/partner-showcase-thumb.png
|
||||
thumb: partner-gallery/partner-showcase-thumb.png
|
||||
tags:
|
||||
- comparison
|
||||
date: '2022-05-26'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
## What is Firebase?
|
||||
|
||||
Now owned by Google, Firebase is a collection of tools aimed at mobile and web developers. At its core is the Firestore database.
|
||||
|
||||
Firestore allows you to store “documents”. These are collections of key:value pairs where the value can be another sub-document. Document based storage is perfect for unstructured data, since two documents in a collection do not necessarily need to have the same structure.
|
||||
|
||||
Firebase also offers other things that web developers find useful like an auth service for user management, and wrappers for other Google services such as Cloud Functions, and File Storage.
|
||||
|
||||
## What is Supabase?
|
||||
|
||||
Supabase is an open source firebase alternative, but instead of being built around a document-based datastore, Supabase offers a relational database management system called PostgreSQL. This comes with a few advantages:
|
||||
|
||||
- It’s open source, so there is zero lock in.
|
||||
- You can query it with SQL, a proven and powerful query language.
|
||||
- It has a long track record of being used at scale.
|
||||
- It’s the database of choice for transactional workloads (think apps and websites, or other things that require near-instant responses to queries).
|
||||
- It comes with decades of [useful postgres extensions and plug-ins](https://supabase.com/docs/guides/database/extensions).
|
||||
|
||||
At Supabase we’ve always been huge fans of Firebase - so we started adding a few things on top of PostgreSQL in an attempt to reach feature parity, including:
|
||||
|
||||
- Auto-generated API - [query your data straight from the client](https://supabase.com/docs/guides/api#rest-api-2).
|
||||
- Realtime - [changes in your data will be streamed directly to your application](https://supabase.com/docs/reference/dart/subscribe).
|
||||
- Auth - [a simple to integrate auth system and SQL based rules engine](https://supabase.com/auth).
|
||||
- Functions - [javascript and typescript functions that deploy out globally](https://supabase.com/edge-functions).
|
||||
- Storage - [hosting images, videos, and pdfs easily](https://supabase.com/storage).
|
||||
|
||||
## How are they similar?
|
||||
|
||||
Both Firebase and Supabase are based on the idea of bringing a superior developer experience to databases. With both platforms you can spin up a new project from directly inside the browser without the need to download any extra tools or software to your machine. Both platforms come with a useful dashboard UI for debugging your data in realtime, which is especially useful for fast iterations when in development.
|
||||
|
||||
Both Firebase and Supabase have invested heavily in client side libraries so you can communicate with your database directly from the client. Firebase has their [Firebase Javascript SDK](https://github.com/firebase/firebase-js-sdk) and Supabase has [supabase-js an isomorphic client](https://github.com/supabase/supabase-js/) that can be used both on the client also on the server in a node-js environment.
|
||||
|
||||
## How are they different?
|
||||
|
||||
Firebase and Supabase differ in several ways. The main one being that Firebase is a document store, whereas Supabase is based on PostgreSQL - a relational, SQL-based database management system.
|
||||
|
||||
There are some other important differences.
|
||||
|
||||
### Open Source
|
||||
|
||||
Supabase is open source. Along with the hosted cloud platform, you can also take the Supabase stack and host it inside your own cloud or run it locally on your machine. There is no vendor lock in.
|
||||
|
||||
### Pricing
|
||||
|
||||
[Firebase charges for reads, writes and deletes](https://firebase.google.com/pricing), which can lead to some unpredictability, especially in the early stages of a project when your application is in heavy development. Supabase [charges based on the amount of data stored](https://supabase.com/pricing), with breathing room for unlimited API requests and an unlimited number of Auth users.
|
||||
|
||||
### Performance
|
||||
|
||||
We created a benchmarking repo where you can compare the performance of both services in different scenarios. Our most recent results show that [Supabase outperforms Firebase by up to 4x](https://github.com/supabase/benchmarks/issues/8) on number of reads per second, and 3.1x on writes per second.
|
||||
|
||||
## How do I migrate from Firebase to Supabase?
|
||||
|
||||
Since Firebase is document based, migrating into a relational database requires you to map your data structure across into a SQL schema. Luckily we’ve built a [handy conversion tool to do it for you](https://github.com/supabase-community/firebase-to-supabase/tree/main/firestore).
|
||||
|
||||
We also have a tool for [migrating Firebase Auth to Supabase Auth](https://github.com/supabase-community/firebase-to-supabase/tree/main/auth). And one for [migrating Firebase Storage files to Supabase Storage](https://github.com/supabase-community/firebase-to-supabase/tree/main/storage).
|
||||
|
||||
These are by far the most complete Firebase to Postgres migration tools available anywhere on the web.
|
||||
|
||||
You can [try Supabase for free](https://app.supabase.io). If you require Enterprise level support with your project or migration, please get in touch using our [Enterprise contact form](https://supabase.com/contact/enterprise).
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Supabase vs Heroku Postgres
|
||||
description: Supabase is an alternative to Heroku Postgres with Enterprise Grade Support available
|
||||
author: ant_wilson
|
||||
image: partner-gallery/partner-showcase-thumb.png
|
||||
thumb: partner-gallery/partner-showcase-thumb.png
|
||||
tags:
|
||||
- comparison
|
||||
date: '2022-05-26'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
## What is Heroku Postgres?
|
||||
|
||||
Heroku is a cloud application platform that offers managed PostgreSQL as a service. They offer 5 levels of Postgres support from the Hobby Tier up to the Shield Tier, each with different levels of features and pricing.
|
||||
|
||||
## What is Supabase?
|
||||
|
||||
Supabase also offers managed Postgres, the main difference is that with each deployment you also get:
|
||||
|
||||
- Auto-generated API - [never write an API again](https://supabase.com/docs/guides/api#rest-api-2)
|
||||
- Realtime - [subscribe to data changes via websockets](https://supabase.com/docs/reference/dart/subscribe)
|
||||
- Auth - [users can log in and out of your application](https://supabase.com/auth)
|
||||
- Functions - [deploy custom logic to the edge](https://supabase.com/edge-functions)
|
||||
- Storage - [serve large files and folders](https://supabase.com/storage)
|
||||
- PgBouncer - [connection pooling useful for serverless computing](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool)
|
||||
|
||||
## How are they similar?
|
||||
|
||||
Heroku Postgres and Supabase both offer:
|
||||
|
||||
- [A web UI for managing your instance](https://supabase.com/docs/guides/database#table-view)
|
||||
- SLAs and Enterprise-grade support packages
|
||||
- Direct SSL connections to Postgres
|
||||
- Postgres Extensions (see [Supabase Extensions](https://supabase.com/docs/guides/database/extensions), [Heroku Extensions](https://devcenter.heroku.com/articles/heroku-postgres-extensions-postgis-full-text-search))
|
||||
- [Backups and PITR](https://supabase.com/blog/2020/08/02/continuous-postgresql-backup-walg)
|
||||
- [Postgres logs](https://supabase.com/docs/guides/platform/logs)
|
||||
- Encryption-at-rest
|
||||
|
||||
## What are the differences?
|
||||
|
||||
### Core Features
|
||||
|
||||
These are some of the key differences between Heroku Postgres and Supabase in terms of features:
|
||||
|
||||
- Supabase is more than just the raw database, it also comes with:
|
||||
- [Connection pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool) so that you won’t run out of connections in a serverless environment.
|
||||
- [Auto-generated APIs](https://supabase.com/docs/guides/api#rest-api-2) based on your schema, so you can communicate with your database directly from the client.
|
||||
- [Realtime API](https://supabase.com/docs/reference/dart/subscribe) is useful for when you want to subscribe to changes to your database over websockets.
|
||||
- [Auth API](https://supabase.com/auth) can be used to leverage Postgres’s Row Level Security model, and control access to sensitive data on a per user, or per group level.
|
||||
- [Functions](https://supabase.com/edge-functions) can be deployed out to the edge directly from the Supabase CLI, which means you can run sensitive business logic or transformations in a serverless fashion.
|
||||
- You can also deploy edge functions to Heroku using their [Dynos](https://www.heroku.com/dynos) runtime in conjunction with something like [Fastly](https://www.fastly.com/).
|
||||
- [File Storage](https://supabase.com/storage) is useful for when your app needs to store large files and folders that aren’t suitable for storing within Postgres itself.
|
||||
- A spreadsheet-like web interface for building your schemas and inspecting data.
|
||||
|
||||
### Pricing
|
||||
|
||||
The two services price quite differently, the key differences being:
|
||||
|
||||
- [Supabase pricing is based around usage](https://supabase.com/pricing), so you only pay for what you use.
|
||||
- Heroku prices based on a tier model with [37 plans to choose from](https://elements.heroku.com/addons/heroku-postgresql#pricing).
|
||||
|
||||
Supabase’s free tier also includes a dedicated Postgres instance, and the best bit is you can upgrade to pro later without any interruptions.
|
||||
|
||||
### Global Deployments
|
||||
|
||||
You may have strict data regulations that you must comply with, so choosing your region can be very important. Here’s how the deployment options stack up:
|
||||
|
||||
- Supabase can be deployed to any one of [12 data centers across the globe](https://github.com/supabase/supabase/discussions/4815#discussioncomment-1915129) (free tier included).
|
||||
- Since Supabase is fully open source - you can also [self host wherever you like](https://supabase.com/docs/guides/hosting/overview).
|
||||
- You can deploy Heroku Postgres to two data centers (US and Europe) however [6 more data centers](https://devcenter.heroku.com/articles/regions) are available on the Enterprise plan.
|
||||
|
||||
## How to migrate from Heroku Postgres to Supabase
|
||||
|
||||
Migrating is surprisingly simple. You just need to use the standard Postgres `pg_dump` and `pg_restore` tools to dump and restore from a backup. We created a handy guide for [migrating from heroku to supabase](https://github.com/supabase-community/heroku-to-supabase).
|
||||
@@ -5,8 +5,8 @@ author: ant_wilson
|
||||
image: 2022-march/thumb.jpg
|
||||
thumb: 2022-march/thumb.jpg
|
||||
tags:
|
||||
- launch-week
|
||||
date: '2022-04-15T11:00:00'
|
||||
- release-notes
|
||||
date: '2022-04-15'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Supabase Beta May 2022
|
||||
description: Product and community updates including wildcard auth redirects, edge functions with webhooks, and Prometheus endpoints for everybody.
|
||||
author: ant_wilson
|
||||
image: 2022-may/thumb.jpg
|
||||
thumb: 2022-may/thumb.jpg
|
||||
tags:
|
||||
- release-notes
|
||||
date: '2022-06-01'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
It's been another packed month of shipping like crazy at Supabase, here's the update on what you might have seen appearing in the product and beyond during the month of May...
|
||||
|
||||
## Wildcard Auth Redirects
|
||||
|
||||
Wildcard redirects are especially useful for Jamstack platforms with preview deployments, like Vercel and Netlify. Now you can add a wildcard domain, like `*.mydomain.com/welcome` and both `x.mydomain.com/welcome` and `y.mydomain.com/welcome` will be be accepted. [Read the Pull Request](https://github.com/supabase/gotrue/pull/334).
|
||||
|
||||
[](https://github.com/supabase/gotrue/pull/334)
|
||||
|
||||
## Supabase Edge functions with Webhooks
|
||||
|
||||
Now you can deploy functions with optional JWT verification. This makes it easier to trigger Edge Functions with webhooks. Use the -no-verify-jwt flag when deploying your functions via the CLI to enable this mode. You can test this out with our [Edge Functions Telegram Bot example](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/telegram-bot).
|
||||
|
||||
[](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/telegram-bot)
|
||||
|
||||
## Metrics for everybody
|
||||
|
||||
We are making another enterprise feature available for all of our users. Our Prometheus-compatible metrics endpoint gives you granular insights on the health and status of your projects, allowing for real-time monitoring, debugging, and alerting for all. [Read the docs](https://supabase.com/docs/guides/platform/metrics).
|
||||
|
||||
[](https://supabase.com/docs/guides/platform/metrics)
|
||||
|
||||
## Improved Policy Editor
|
||||
|
||||
The Policy editor now includes a “Target roles” field. We now have a strong recommendation to use this over the auth.role() = 'authenticated' technique. In one customer’s database, this reduced a query from 46 seconds to 7 milliseconds. Sometimes even we get it wrong - luckily you can trust that Postgres has it right. [Check it out](https://app.supabase.com/project/_/auth/policies).
|
||||
|
||||
[](https://app.supabase.com/project/_/auth/policies)
|
||||
|
||||
## State of DB 2022
|
||||
|
||||
Basedash conducted a survey that takes the pulse of the current databases that teams, individuals, startups, enterprises and hobbyists are using.
|
||||
|
||||
Supabase was listed in two categories, including the first place in satisfaction for hosting providers (84% of users would use us again 🙏). [Full results](https://stateofdb.com/).
|
||||
|
||||
[](https://stateofdb.com/)
|
||||
|
||||
## Supabase Happy Hour #5
|
||||
|
||||
For the fifth edition of our Happy Hour streaming, we had a chat with our CEO and co-founder, Paul Copplestone. We went through some of the history of Supabase, our culture, why PostgreSQL was the right call, and hints about the future of the product. [Watch the episode](https://www.youtube.com/watch?v=_k7GqVDSFvw).
|
||||
|
||||
[](https://www.youtube.com/watch?v=_k7GqVDSFvw)
|
||||
|
||||
## Supabase on Software Engineering Radio
|
||||
|
||||
I chatted with SE Radio host Jeremy Jung about all things Supabase: building an API layer with postgREST, authentication using GoTrue, row-level security, forking open source projects, using the write ahead log to implement real time updates, provisioning and monitoring databases, user support, incidents, and open source licenses. [Listen to the podcast](https://www.se-radio.net/2022/05/episode-511-ant-wilson-on-supabase-postgres-as-a-service/).
|
||||
|
||||
[](https://www.se-radio.net/2022/05/episode-511-ant-wilson-on-supabase-postgres-as-a-service/)
|
||||
|
||||
## Supabase is Hiring for Sales Assist
|
||||
|
||||
There is an opportunity to work inside the growth team at Supabase. It's still a small team so you'd be getting in at the ground floor. If you love dev tools and are passionate about talking to customers, then you're who we're looking for! [Read the role spec here](https://boards.greenhouse.io/supabase/jobs/4530789004).
|
||||
|
||||
[](https://boards.greenhouse.io/supabase/jobs/4530789004)
|
||||
|
||||
## Partner Showcase
|
||||
|
||||
### Deepnote
|
||||
|
||||
Deepnote is a data notebook that’s built for collaboration — Jupyter compatible, works magically in the cloud, and sharing is as easy as sending a link.
|
||||
|
||||
You can connect your Supabase Postgres Database to your Deepnote notebooks to quickly analyze data and share your findings with others. [Check out the guide](https://docs.deepnote.com/integrations/supabase).
|
||||
|
||||
[](https://docs.deepnote.com/integrations/supabase)
|
||||
|
||||
## Made with Supabase
|
||||
|
||||
### Polypane
|
||||
|
||||
A browser that help you design, build and test better websites. Who doesn't want that? [Polypane](https://polypane.app/) helps with responsive design, accessibility, SEO, performance, design accuracy, semantic HTML, social media tags... and everything else. Built by: [@kilianvalkhof](https://twitter.com/kilianvalkhof).
|
||||
|
||||
[](https://polypane.app/)
|
||||
|
||||
### Pawternity Hub
|
||||
|
||||
[Pawternity Hub](https://pawternityhub.netlify.app/) is a Pet Adoption website where people can find local pets around their area to adopt. Started in a hackathon while brainstorming ideas to help the community, it was built with React and Bootstrap and uses the PetFinder API. Built by [@NathanJoSuarez](https://twitter.com/NathanJoSuarez).
|
||||
|
||||
[](https://pawternityhub.netlify.app/)
|
||||
|
||||
Discover other projects: Made with Supabase
|
||||
|
||||
## Issue Bounties
|
||||
|
||||
- Fix: [crash in aws sdk](https://issuehunt.io/r/supabase/storage-api/issues/141)
|
||||
|
||||
## Quick Product Updates
|
||||
|
||||
- Fix: [Added reuse interval for token refresh](https://github.com/supabase/gotrue/pull/466)
|
||||
|
||||
## Community Highlights
|
||||
|
||||
- Morrow is looking for a full Senior Full Stack TypeScript Developer (Supabase Focus): [Job post](https://jobs.themorrow.digital/24826)
|
||||
- The StackOverflow podcast with Matt and Cassidoo talking about Supabase’s Series B and open source. [Podcast](https://stackoverflow.blog/2022/05/17/open-source-is-winning-over-developers-and-investors-ep-442/)
|
||||
- Migrate your MySQL database to Postgres with Supabase, Hasura & pgloader. [Blog](https://medium.com/@adi_myth/migrate-your-mysql-database-to-postgres-with-supabase-hasura-pgloader-3ef63bedd38)
|
||||
- Here’s Why You Should Ditch MongoDB and Switch to Supabase. [Blog](https://javascript.plainenglish.io/heres-why-you-should-ditch-mongodb-and-switch-to-supabase-374b43f4ebc2)
|
||||
- What is Supabase? [Video](https://www.youtube.com/watch?v=v2W7QJMMIA0)
|
||||
- Anima converted Supabase’s Social Auth component into a native Figma component, with over 400 variants. [Link](https://unruffled-hoover-de9320.netlify.app/?path=/story/auth-auth--with-coloured-social-auth&args=socialLayout:horizontal)
|
||||
- How To Build A Nuxt 3 Ionic Capacitor Starter App, Supabase Setup and Authentication. [Blog](https://dev.to/aaronksaunders/how-to-build-a-nuxt-3-ionic-capacitor-starter-app-supabase-setup-and-authentication-3gd0)
|
||||
- How to create and test a GitHub Action that generates Supabase database types. [Blog](https://dev.to/lyqht/how-to-create-and-test-a-github-action-that-generates-supabase-database-types-1l6b)
|
||||
|
||||
## Supabase GitHub
|
||||
|
||||
We hit 49K stars!!: [repository.surf/supabase](https://repository.surf/supabase)
|
||||
|
||||
[](https://repository.surf/supabase)
|
||||
|
||||
## Meme Zone
|
||||
|
||||
If you made it this far in the blog post you deserve a treat. [Follow us on Twitter](https://twitter.com/supabase) for more.
|
||||
|
||||
[](https://twitter.com/supabase)
|
||||
|
||||
## Get started with Supabase
|
||||
|
||||
- Start using Supabase today: **[app.supabase.com](https://app.supabase.com/)**
|
||||
- Make sure to **[star us on GitHub](https://github.com/supabase/supabase)**
|
||||
- Follow us **[on Twitter](https://twitter.com/supabase)**
|
||||
- Subscribe to our **[YouTube channel](https://www.youtube.com/c/supabase)**
|
||||
- Become a **[sponsor](https://github.com/sponsors/supabase)**
|
||||
- Join the **[team](https://about.supabase.com/careers)**
|
||||
@@ -64,7 +64,7 @@ function CodeBlock(props: CodeBlockProps) {
|
||||
const style = shouldHighlightLine ? {} : { filter: 'grayscale(75%)', opacity: 0.5 }
|
||||
|
||||
return {
|
||||
class: classNames(
|
||||
className: classNames(
|
||||
CodeBlockStyles['code-line'],
|
||||
shouldHighlightLines && shouldHighlightLine && CodeBlockStyles['code-line--flash']
|
||||
),
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function BecomeAPartner({ supabase }: { supabase: SupabaseClient
|
||||
|
||||
<Form initialValues={INITIAL_VALUES} validate={validate} onSubmit={handleFormSubmit}>
|
||||
{({ isSubmitting }: any) => (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1">
|
||||
<div className="col-span-2 h-24">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<Select
|
||||
id="type"
|
||||
name="type"
|
||||
@@ -89,67 +89,73 @@ export default function BecomeAPartner({ supabase }: { supabase: SupabaseClient
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<Input
|
||||
label="First Name *"
|
||||
id="first"
|
||||
name="first"
|
||||
layout="vertical"
|
||||
placeholder="Jane"
|
||||
/>
|
||||
<div className="flex space-x-2 sm:space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="First Name *"
|
||||
id="first"
|
||||
name="first"
|
||||
layout="vertical"
|
||||
placeholder="Jane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Last Name *"
|
||||
id="last"
|
||||
name="last"
|
||||
layout="vertical"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<Input
|
||||
label="Last Name *"
|
||||
id="last"
|
||||
name="last"
|
||||
layout="vertical"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
<div className="flex space-x-2 sm:space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Company Name"
|
||||
id="company"
|
||||
name="company"
|
||||
layout="vertical"
|
||||
placeholder="Supa Inc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<InputNumber
|
||||
label="Company Size"
|
||||
id="size"
|
||||
name="size"
|
||||
layout="vertical"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<Input
|
||||
label="Company Name"
|
||||
id="company"
|
||||
name="company"
|
||||
layout="vertical"
|
||||
placeholder="Supa Inc."
|
||||
/>
|
||||
<div className="flex space-x-2 sm:space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Job Title"
|
||||
id="title"
|
||||
name="title"
|
||||
layout="vertical"
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Business email *"
|
||||
id="email"
|
||||
name="email"
|
||||
layout="vertical"
|
||||
placeholder="janedoe@example.sg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<InputNumber
|
||||
label="Company Size"
|
||||
id="size"
|
||||
name="size"
|
||||
layout="vertical"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<Input
|
||||
label="Job Title"
|
||||
id="title"
|
||||
name="title"
|
||||
layout="vertical"
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<Input
|
||||
label="Business email *"
|
||||
id="email"
|
||||
name="email"
|
||||
layout="vertical"
|
||||
placeholder="janedoe@example.sg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<div>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
id="phone"
|
||||
@@ -159,7 +165,7 @@ export default function BecomeAPartner({ supabase }: { supabase: SupabaseClient
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-24">
|
||||
<div>
|
||||
<Select
|
||||
label="Country / Main Timezone"
|
||||
id="country"
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
"author_url": "https://github.com/geromegrignon",
|
||||
"author_img": "https://avatars.githubusercontent.com/u/32737308",
|
||||
"repo_name": "angular-todo-list",
|
||||
"repo_url": "https://github.com/supabase/supabase/tree/master/examples/angular-todo-list",
|
||||
"repo_url": "https://github.com/supabase/supabase/tree/master/examples/todo-list/angular-todo-list",
|
||||
"vercel_deploy_url": "",
|
||||
"demo_url": ""
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type Investor = {
|
||||
name: string
|
||||
round: 'SEED' | 'A'
|
||||
round: 'SEED' | 'A' | 'B'
|
||||
title: string
|
||||
img: string
|
||||
lead: boolean
|
||||
@@ -36,6 +36,15 @@ const data: Investor[] = [
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Felicis',
|
||||
round: 'B',
|
||||
title: 'Felicis',
|
||||
img: '/images/logos/felicis.png',
|
||||
lead: true,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Tom Preston-Werner',
|
||||
round: 'A',
|
||||
|
||||
@@ -6,6 +6,12 @@ type PressItem = {
|
||||
}
|
||||
|
||||
const data: PressItem[] = [
|
||||
{
|
||||
type: 'article',
|
||||
href: 'https://techcrunch.com/2022/05/10/supabase-raises-80m-series-b-for-its-open-source-firebase-alternative/',
|
||||
title: 'Supabase raises $80M Series B for its open source Firebase alternative',
|
||||
publisher: 'TechCrunch',
|
||||
},
|
||||
{
|
||||
type: 'article',
|
||||
href: 'https://techcrunch.com/2021/09/09/supabase-raises-30m-for-its-open-source-insta-backend',
|
||||
|
||||
@@ -15,6 +15,7 @@ async function generate() {
|
||||
'pages/*/*.tsx',
|
||||
'data/**/*.mdx',
|
||||
'_blog/*.mdx',
|
||||
'_alternatives/*.mdx',
|
||||
'!pages/index.tsx',
|
||||
'!data/*.mdx',
|
||||
'!pages/_*.js',
|
||||
@@ -33,6 +34,7 @@ async function generate() {
|
||||
.replace('pages', '')
|
||||
// add a `/` for blog posts
|
||||
.replace('_blog', '/blog')
|
||||
.replace('_alternatives', '/alternatives')
|
||||
.replace('.tsx', '')
|
||||
.replace('.mdx', '')
|
||||
// replace the paths for nested 'index' based routes
|
||||
@@ -42,6 +44,9 @@ async function generate() {
|
||||
|
||||
let route = path === '/index' ? '' : path
|
||||
|
||||
if (route === '/alternatives/[slug]') return null
|
||||
if (route === '/partners/[slug]') return null
|
||||
|
||||
//
|
||||
// blog specific urls
|
||||
//
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import authors from 'lib/authors.json'
|
||||
import hydrate from 'next-mdx-remote/hydrate'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { Gfm } from 'remark-gfm'
|
||||
import CTABanner from '~/components/CTABanner'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import { generateReadingTime } from '~/lib/helpers'
|
||||
|
||||
interface Props {
|
||||
components: React.ReactNode
|
||||
props: any
|
||||
gfm: Gfm
|
||||
slug: string
|
||||
}
|
||||
|
||||
const LayoutComparison = ({ components, props, gfm, slug }: Props) => {
|
||||
// @ts-ignore
|
||||
const content = hydrate(props.blog.content, { components })
|
||||
|
||||
const authorArray = props.blog.author.split(',')
|
||||
|
||||
const author = []
|
||||
for (let i = 0; i < authorArray.length; i++) {
|
||||
author.push(
|
||||
// @ts-ignore
|
||||
authors.find((authors: string) => {
|
||||
// @ts-ignore
|
||||
return authors.author_id === authorArray[i]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const { basePath } = useRouter()
|
||||
|
||||
const NextCard = (props: any) => {
|
||||
const { post, label, className } = props
|
||||
return (
|
||||
<Link href={`${post.url}`} as={`${post.url}`}>
|
||||
<div className={className}>
|
||||
<div className="border-scale-500 hover:bg-scale-100 dark:hover:bg-scale-300 cursor-pointer rounded border p-6 transition">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-scale-900 text-sm">{label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-scale-1200 text-lg">{post.title}</h4>
|
||||
<p className="small">{post.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={props.blog.title}
|
||||
openGraph={{
|
||||
title: props.blog.title,
|
||||
description: props.blog.description,
|
||||
url: `https://supabase.com/blog/${props.blog.slug}`,
|
||||
type: 'article',
|
||||
article: {
|
||||
//
|
||||
// to do: add expiration and modified dates
|
||||
// https://github.com/garmeeh/next-seo#article
|
||||
publishedTime: props.blog.date,
|
||||
//
|
||||
// to do: author urls should be internal in future
|
||||
// currently we have external links to github profiles
|
||||
authors: [props.blog.author_url],
|
||||
tags: props.blog.tags.map((cat: string) => {
|
||||
return cat
|
||||
}),
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: `https://supabase.com${basePath}/images/blog/${
|
||||
props.blog.image ? props.blog.image : props.blog.thumb
|
||||
}`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<DefaultLayout>
|
||||
<article className="mx-auto max-w-5xl px-8 py-16 sm:px-16 xl:px-20">
|
||||
{/* Title and description */}
|
||||
<div className="mb-16 max-w-5xl space-y-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-brand-900 text-center">Alternative</p>
|
||||
<h1 className="h1 text-center">{props.blog.title}</h1>
|
||||
<div className="text-scale-900 flex justify-center space-x-3 text-sm">
|
||||
<p>{props.blog.date}</p>
|
||||
<p>•</p>
|
||||
<p>{generateReadingTime(props.blog.content.renderedOutput)}</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
{author.map((author: any) => {
|
||||
return (
|
||||
<div className="mt-6 mb-8 mr-4 w-max lg:mb-0">
|
||||
<Link href={author.author_url}>
|
||||
<a className="cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
{author.author_image_url && (
|
||||
<div className="w-10">
|
||||
<Image
|
||||
src={author.author_image_url}
|
||||
className="dark:border-dark rounded-full border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
layout="responsive"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-scale-1200 mb-0 text-sm">{author.author}</span>
|
||||
<span className="text-scale-900 mb-0 text-xs">{author.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
{/* Content */}
|
||||
<div className="prose prose-docs max-w-none">{content}</div>
|
||||
<div className="py-16">
|
||||
<div className="text-scale-900 dark:text-scale-1000 text-sm">Share this article</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Link
|
||||
passHref
|
||||
href={`https://twitter.com/share?text=${props.blog.title}&url=https://supabase.com/blog/${props.blog.slug}`}
|
||||
>
|
||||
<a target="_blank" className="text-scale-900 hover:text-scale-1200">
|
||||
<svg
|
||||
height="26"
|
||||
width="26"
|
||||
viewBox="-89 -46.8 644 446.8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="m154.729 400c185.669 0 287.205-153.876 287.205-287.312 0-4.37-.089-8.72-.286-13.052a205.304 205.304 0 0 0 50.352-52.29c-18.087 8.044-37.55 13.458-57.968 15.899 20.841-12.501 36.84-32.278 44.389-55.852a202.42 202.42 0 0 1 -64.098 24.511c-18.42-19.628-44.644-31.904-73.682-31.904-55.744 0-100.948 45.222-100.948 100.965 0 7.925.887 15.631 2.619 23.025-83.895-4.223-158.287-44.405-208.074-105.504a100.739 100.739 0 0 0 -13.668 50.754c0 35.034 17.82 65.961 44.92 84.055a100.172 100.172 0 0 1 -45.716-12.63c-.015.424-.015.837-.015 1.29 0 48.903 34.794 89.734 80.982 98.986a101.036 101.036 0 0 1 -26.617 3.553c-6.493 0-12.821-.639-18.971-1.82 12.851 40.122 50.115 69.319 94.296 70.135-34.549 27.089-78.07 43.224-125.371 43.224a204.9 204.9 0 0 1 -24.078-1.399c44.674 28.645 97.72 45.359 154.734 45.359"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
passHref
|
||||
href={`https://www.linkedin.com/shareArticle?url=https://supabase.com/blog/${props.blog.slug}&title=${props.blog.title}`}
|
||||
>
|
||||
<a target="_blank" className="text-scale-900 hover:text-scale-1200">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 5 1036 990"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M0 120c0-33.334 11.667-60.834 35-82.5C58.333 15.833 88.667 5 126 5c36.667 0 66.333 10.666 89 32 23.333 22 35 50.666 35 86 0 32-11.333 58.666-34 80-23.333 22-54 33-92 33h-1c-36.667 0-66.333-11-89-33S0 153.333 0 120zm13 875V327h222v668H13zm345 0h222V622c0-23.334 2.667-41.334 8-54 9.333-22.667 23.5-41.834 42.5-57.5 19-15.667 42.833-23.5 71.5-23.5 74.667 0 112 50.333 112 151v357h222V612c0-98.667-23.333-173.5-70-224.5S857.667 311 781 311c-86 0-153 37-201 111v2h-1l1-2v-95H358c1.333 21.333 2 87.666 2 199 0 111.333-.667 267.666-2 469z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-8 py-8 lg:grid-cols-1">
|
||||
<div>
|
||||
{props.prevPost && <NextCard post={props.prevPost} label="Previous comparison" />}
|
||||
</div>
|
||||
<div>
|
||||
{props.nextPost && (
|
||||
<NextCard post={props.nextPost} label="Next comparison" className="text-right" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<CTABanner />
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutComparison
|
||||
@@ -7,12 +7,14 @@ import { generateReadingTime } from './helpers'
|
||||
// based on YYYY-MM-DD format
|
||||
const FILENAME_SUBSTRING = 11
|
||||
|
||||
type Directories = '_blog' | '_case-studies'
|
||||
type Directories = '_blog' | '_case-studies' | '_alternatives'
|
||||
|
||||
export const getSortedPosts = (directory: Directories, limit?: number, tags?: any) => {
|
||||
//Finding directory named "blog" from the current working directory of Node.
|
||||
const postDirectory = path.join(process.cwd(), directory)
|
||||
|
||||
console.log(postDirectory)
|
||||
|
||||
//Reads all the files in the post directory
|
||||
const fileNames = fs.readdirSync(postDirectory)
|
||||
|
||||
@@ -109,8 +111,12 @@ export const getPostdata = async (slug: string, directory: string) => {
|
||||
//Finding directory named "blog" from the current working directory of Node.
|
||||
const postDirectory = path.join(process.cwd(), directory)
|
||||
|
||||
console.log('postDirectory', postDirectory)
|
||||
|
||||
const fullPath = path.join(postDirectory, `${slug}.mdx`)
|
||||
|
||||
console.log('fullPath', fullPath)
|
||||
|
||||
const postContent = fs.readFileSync(fullPath, 'utf8')
|
||||
|
||||
return postContent
|
||||
|
||||
@@ -654,6 +654,11 @@ module.exports = withMDX({
|
||||
source: '/learn-more',
|
||||
destination: '/?utm_source=event&utm_medium=billboard&utm_campaign=aws-atlanta',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/reference/javascript/supabase-client',
|
||||
destination: '/docs/reference',
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/* .toc a {
|
||||
text-decoration: none !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.toc > ul > li {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
@apply text-sm !important;
|
||||
padding-left: 1rem !important;
|
||||
}
|
||||
|
||||
.toc ul > li:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: none;
|
||||
border-radius: 50%;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
top: 0.6875em;
|
||||
left: 0.25em;
|
||||
}
|
||||
|
||||
.header:hover svg,
|
||||
.header svg:hover {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.article h1,
|
||||
.article h2,
|
||||
.article h3,
|
||||
.article h4,
|
||||
.article h5 {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.article {
|
||||
@apply mb-16;
|
||||
}
|
||||
|
||||
.article > div > pre {
|
||||
padding: 0;
|
||||
background: none;
|
||||
} */
|
||||
@@ -0,0 +1,101 @@
|
||||
import matter from 'gray-matter'
|
||||
import authors from 'lib/authors.json'
|
||||
import hydrate from 'next-mdx-remote/hydrate'
|
||||
import renderToString from 'next-mdx-remote/render-to-string'
|
||||
import React from 'react'
|
||||
import Avatar from '~/components/Avatar'
|
||||
import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import ImageGrid from '~/components/ImageGrid'
|
||||
import Quote from '~/components/Quote'
|
||||
import LayoutComparison from '~/layouts/comparison'
|
||||
import { getAllPostSlugs, getPostdata, getSortedPosts } from '~/lib/posts'
|
||||
|
||||
// import all components used in blog articles here
|
||||
// for instance, if you use a button, you must add `Button` in the components object below.
|
||||
const components = {
|
||||
CodeBlock,
|
||||
Quote,
|
||||
Avatar,
|
||||
code: (props: any) => {
|
||||
return <CodeBlock {...props} />
|
||||
},
|
||||
ImageGrid,
|
||||
}
|
||||
|
||||
// plugins for next-mdx-remote
|
||||
const gfm = require('remark-gfm')
|
||||
const slug = require('rehype-slug')
|
||||
|
||||
// table of contents extractor
|
||||
const toc = require('markdown-toc')
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const paths = getAllPostSlugs('_alternatives')
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }: any) {
|
||||
const filePath = `${params.slug}`
|
||||
const postContent = await getPostdata(filePath, '_alternatives')
|
||||
const { data, content } = matter(postContent)
|
||||
|
||||
const mdxSource: any = await renderToString(content, {
|
||||
components,
|
||||
scope: data,
|
||||
mdxOptions: {
|
||||
remarkPlugins: [gfm],
|
||||
rehypePlugins: [slug],
|
||||
},
|
||||
})
|
||||
|
||||
const relatedPosts = getSortedPosts('_alternatives', 5, mdxSource.scope.tags)
|
||||
|
||||
const allPosts = getSortedPosts('_alternatives')
|
||||
|
||||
const currentIndex = allPosts
|
||||
.map(function (e) {
|
||||
return e.slug
|
||||
})
|
||||
.indexOf(filePath)
|
||||
|
||||
const nextPost = allPosts[currentIndex + 1]
|
||||
const prevPost = allPosts[currentIndex - 1]
|
||||
|
||||
return {
|
||||
props: {
|
||||
prevPost: currentIndex === 0 ? null : prevPost ? prevPost : null,
|
||||
nextPost: currentIndex === allPosts.length ? null : nextPost ? nextPost : null,
|
||||
relatedPosts,
|
||||
blog: {
|
||||
slug: `${params.year}/${params.month}/${params.day}/${params.slug}`,
|
||||
content: mdxSource,
|
||||
...data,
|
||||
toc: toc(content, { maxdepth: data.toc_depth ? data.toc_depth : 2 }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function BlogPostPage(props: any) {
|
||||
// @ts-ignore
|
||||
const content = hydrate(props.blog.content, { components })
|
||||
const authorArray = props.blog.author.split(',')
|
||||
|
||||
const author = []
|
||||
for (let i = 0; i < authorArray.length; i++) {
|
||||
author.push(
|
||||
// @ts-ignore
|
||||
authors.find((authors: string) => {
|
||||
// @ts-ignore
|
||||
return authors.author_id === authorArray[i]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return <LayoutComparison components={components} props={props} gfm={gfm} slug={slug} />
|
||||
}
|
||||
|
||||
export default BlogPostPage
|
||||
@@ -247,7 +247,7 @@ const Investors = () => {
|
||||
<>
|
||||
<p>
|
||||
<p className="text-lg">
|
||||
We've raised over $36 million in funding, backed by some of the world's leading
|
||||
We've raised over $116 million in funding, backed by some of the world's leading
|
||||
investors.
|
||||
</p>
|
||||
</p>
|
||||
@@ -256,7 +256,7 @@ const Investors = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-5 mb-16 grid max-w-lg gap-0.5 lg:max-w-none lg:grid-cols-3">
|
||||
<div className="mx-auto mt-5 mb-16 grid max-w-lg gap-0.5 lg:max-w-none lg:grid-cols-4">
|
||||
{InvestorData.filter((x) => x.lead === true).map((x) => (
|
||||
<div key={x.name}>
|
||||
<div
|
||||
@@ -311,7 +311,7 @@ const Press = () => {
|
||||
<div>
|
||||
<SectionHeader title={'Press'} />
|
||||
</div>
|
||||
<div className="mx-auto mt-5 grid gap-5 lg:max-w-none lg:grid-cols-2">
|
||||
<div className="mx-auto mt-5 grid gap-5 lg:max-w-none lg:grid-cols-3">
|
||||
{PressData.filter((x) => x.type == 'article').map((x) => (
|
||||
<a href={x.href} key={x.href} target="_blank">
|
||||
<Card key={`press_${x.href}`} hoverable className="dark:bg-scale-400 bg-white">
|
||||
|
||||
@@ -53,7 +53,7 @@ function Database() {
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/auth`,
|
||||
url: `https://supabase.com/database`,
|
||||
images: [
|
||||
{
|
||||
url: `https://supabase.com${basePath}/images/product/database/database-og.jpg`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// NOTE: this is a placeholder for our future Next.js marketing page. For now it redirects to our Next.js guide.
|
||||
|
||||
function Nextjs() {
|
||||
// base path for images
|
||||
const router = useRouter()
|
||||
const { basePath } = router
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/docs/guides/with-nextjs')
|
||||
}, [])
|
||||
|
||||
const meta_title = 'Next.js Database with Auth, Realtime, Fiel Storage, and more.'
|
||||
const meta_description =
|
||||
'Supabase is the fastest way to build performant Next.js apps with Authentication & User Management, Realtime, File Storage on top of PostgreSQL Databases.'
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={meta_title}
|
||||
description={meta_description}
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/nextjs`,
|
||||
images: [
|
||||
{
|
||||
url: `https://supabase.com${basePath}/images/product/database/database-og.jpg`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Nextjs
|
||||
@@ -0,0 +1,3 @@
|
||||
import Nextjs from './Nextjs'
|
||||
|
||||
export default Nextjs
|
||||
@@ -653,7 +653,9 @@ export default function IndexPage() {
|
||||
{pricingFaq.map((faq, i) => {
|
||||
return (
|
||||
<Accordion.Item key={i} header={faq.question} id={`faq--${i.toString()}`}>
|
||||
<ReactMarkdown className="text-scale-1100">{faq.answer}</ReactMarkdown>
|
||||
<ReactMarkdown className="text-scale-1100 prose max-w-full">
|
||||
{faq.answer}
|
||||
</ReactMarkdown>
|
||||
</Accordion.Item>
|
||||
)
|
||||
})}
|
||||
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 154 KiB |
@@ -5,9 +5,17 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Wed, 25 May 2022 16:00:00 GMT</lastBuildDate>
|
||||
<lastBuildDate>Tue, 31 May 2022 16:00:00 GMT</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/06/07/beta-update-may-2022</guid>
|
||||
<title>Supabase Beta May 2022</title>
|
||||
<link>https://supabase.com/blog/2022/06/07/beta-update-may-2022</link>
|
||||
<description>Product and community updates including wildcard auth redirects, edge functions with webhooks, and Prometheus endpoints for everybody.</description>
|
||||
<pubDate>Tue, 31 May 2022 16:00:00 GMT</pubDate>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/05/26/how-supabase-accelerates-development-of-all-pull-together</guid>
|
||||
<title>How Mike Lyndon is using Supabase to accelerate development of AllPullTogether</title>
|
||||
|
||||
@@ -66,12 +66,6 @@
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/partners/[slug]</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/storage</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
@@ -649,4 +643,24 @@
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc
|
||||
>https://supabase.com/blog/2022/05/26/how-supabase-accelerates-development-of-all-pull-together</loc
|
||||
>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/alternatives/supabase-vs-firebase</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/alternatives/supabase-vs-heroku-postgres</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -1,12 +1,9 @@
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
|
||||
const FUNCTION_SECRET = Deno.env.get('FUNCTION_SECRET')
|
||||
const HUBSPOT_API_KEY = Deno.env.get('HUBSPOT_API_KEY')
|
||||
const OWNER_ID = Deno.env.get('OWNER_ID')
|
||||
const ENTERPRISE_PIPELINE = Deno.env.get('ENTERPRISE_PIPELINE')
|
||||
const PARTNERS_PIPELINE = Deno.env.get('PARTNERS_PIPELINE')
|
||||
const ENTERPRISE_DEAL_STAGE = Deno.env.get('ENTERPRISE_DEAL_STAGE')
|
||||
const PARTNERS_DEAL_STAGE = Deno.env.get('PARTNERS_DEAL_STAGE')
|
||||
const HUBSPOT_PORTAL = Deno.env.get('HUBSPOT_PORTAL')
|
||||
const PARTNER_FORM_ID = Deno.env.get('PARTNER_FORM_ID')
|
||||
const ENTERPRISE_FORM_ID = Deno.env.get('ENTERPRISE_FORM_ID')
|
||||
|
||||
serve(async (req) => {
|
||||
const requestSecret = req.headers.get('x-function-secret')
|
||||
@@ -17,14 +14,51 @@ serve(async (req) => {
|
||||
|
||||
const input = await req.json()
|
||||
|
||||
const firstName = input.record.contact_first_name || input.record.first
|
||||
const lastName = input.record.contact_last_name || input.record.last
|
||||
const supabase_table_id = input.record.id
|
||||
const country = input.record.country
|
||||
const form_note = input.record.details
|
||||
const company = input.record.company_name || input.record.company
|
||||
const company_size = input.record.company_size || input.record.size
|
||||
const email = input.record.contact_email || input.record.email
|
||||
const phone = input.record.contact_phone || input.record.phone
|
||||
const lastName = input.record.contact_last_name || input.record.last
|
||||
const firstName = input.record.contact_first_name || input.record.first
|
||||
const jobtitle = input.record.title
|
||||
const website = input.record.website
|
||||
const partner_gallery_type = input.record.type
|
||||
|
||||
const contactResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/contacts?hapikey=${HUBSPOT_API_KEY}`,
|
||||
let formData: object = {
|
||||
supabase_table_id,
|
||||
country,
|
||||
form_note,
|
||||
company,
|
||||
company_size,
|
||||
email,
|
||||
phone,
|
||||
lastName,
|
||||
firstName,
|
||||
}
|
||||
|
||||
if (input.table === 'partner_contacts') {
|
||||
formData = {
|
||||
...formData,
|
||||
jobtitle,
|
||||
website,
|
||||
partner_gallery_type,
|
||||
}
|
||||
}
|
||||
|
||||
const fields: { objectTypeId: '0-1'; name: string; value: string }[] = []
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
fields.push({ objectTypeId: '0-1', name: key, value: value })
|
||||
}
|
||||
|
||||
const formId = input.table === 'partner_contacts' ? PARTNER_FORM_ID : ENTERPRISE_FORM_ID
|
||||
|
||||
// Using HubSpot's submit data to a form API
|
||||
// https://legacydocs.hubspot.com/docs/methods/forms/submit_form
|
||||
const formResponse = await fetch(
|
||||
`https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL}/${formId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -32,143 +66,18 @@ serve(async (req) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
company,
|
||||
email,
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
phone,
|
||||
},
|
||||
fields,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const body = await contactResponse.json()
|
||||
// Will create a deal using HubSpot's workflow tool instead of via API
|
||||
|
||||
let contactId: string | null = null
|
||||
|
||||
if (contactResponse.ok) {
|
||||
contactId = body.id
|
||||
} else {
|
||||
if (body.status === 'error' && body.category === 'CONFLICT') {
|
||||
// fetch existing contact
|
||||
const contactAssociateResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/contacts/${email}?idProperty=email&hapikey=${HUBSPOT_API_KEY}`
|
||||
)
|
||||
|
||||
const body = await contactAssociateResponse.json()
|
||||
|
||||
if (contactAssociateResponse.ok) {
|
||||
contactId = body.id
|
||||
} else {
|
||||
console.log(`Failed to fetch contact`, body)
|
||||
return new Response(`Failed to fetch contact`, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
console.log(`Failed to create contact`, body)
|
||||
return new Response(`Failed to create contact`, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const noteContent = Object.entries(input.record).reduce(
|
||||
(acc, [key, value]) => acc + `${key}: ${value || 'NOT_PROVIDED'}<br />`,
|
||||
''
|
||||
)
|
||||
|
||||
// Add note
|
||||
const noteResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/notes?hapikey=${HUBSPOT_API_KEY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
hs_timestamp: Date.now(),
|
||||
hs_note_body: noteContent,
|
||||
hubspot_owner_id: OWNER_ID,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const noteBody = await noteResponse.json()
|
||||
|
||||
if (!noteResponse.ok) {
|
||||
console.log(`Failed to add note`, noteBody)
|
||||
return new Response(`Failed to add note`, { status: 500 })
|
||||
}
|
||||
|
||||
const noteId = noteBody.id
|
||||
|
||||
// Associate note with contact
|
||||
const noteAssociateResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/notes/${noteId}/associations/contacts/${contactId}/note_to_contact?hapikey=${HUBSPOT_API_KEY}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!noteAssociateResponse.ok) {
|
||||
console.log(`Failed to associate note`, await noteAssociateResponse.json())
|
||||
return new Response(`Failed to associate note`, { status: 500 })
|
||||
}
|
||||
|
||||
// Create deal
|
||||
const dealCloseDate = new Date()
|
||||
dealCloseDate.setDate(dealCloseDate.getDate() + 28) // 28 days
|
||||
|
||||
const dealResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/deals?hapikey=${HUBSPOT_API_KEY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
closedate: dealCloseDate.toISOString(),
|
||||
dealname: company || `${firstName} ${lastName}`,
|
||||
dealstage:
|
||||
input.table === 'enterprise_contacts' ? ENTERPRISE_DEAL_STAGE : PARTNERS_DEAL_STAGE,
|
||||
hubspot_owner_id: OWNER_ID,
|
||||
pipeline: input.table === 'enterprise_contacts' ? ENTERPRISE_PIPELINE : PARTNERS_PIPELINE,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const dealBody = await dealResponse.json()
|
||||
|
||||
if (!dealResponse.ok) {
|
||||
console.log(`Failed to create deal`, dealBody)
|
||||
return new Response(`Failed to create deal: ` + dealBody.message, { status: 500 })
|
||||
}
|
||||
|
||||
const dealId = dealBody.id
|
||||
|
||||
// Associate contact with a deal
|
||||
const dealAssociateResponse = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/contacts/${contactId}/associations/deals/${dealId}/contact_to_deal?hapikey=${HUBSPOT_API_KEY}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!dealAssociateResponse.ok) {
|
||||
console.log(`Failed to associate contact with deal`, await dealAssociateResponse.json())
|
||||
return new Response(`Failed to associate contact with deal`, { status: 500 })
|
||||
if (!formResponse.ok) {
|
||||
console.log(`Failed to submit form data to HubSpot`, await formResponse.json())
|
||||
return new Response(`Failed to submit form data to HubSpot`, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ done: true }), {
|
||||
|
||||
@@ -11,6 +11,7 @@ The function examples are located in [`./supabase/functions`](./supabase/functio
|
||||
- [`browser-with-cors`](./supabase/functions/browser-with-cors/index.ts): Handle CORS headers for function invocations from browser environments.
|
||||
- [`select-from-table-with-auth-rls`](./supabase/functions/select-from-table-with-auth-rls/index.ts): Retrieve data from an authenticated user via RLS.
|
||||
- [`send-email-smtp`](./supabase/functions/send-email-smtp/index.ts): Send an email using SMTP credentials.
|
||||
- [`stripe-webhooks`](./supabase/functions/stripe-webhooks/index.ts): Handle Stripe Webhooks.
|
||||
- [`telegram-bot`](./supabase/functions/telegram-bot/index.ts): Webhook handler for Telegram bots using [grammY](https://grammy.dev/).
|
||||
|
||||
## Develop locally
|
||||
|
||||
@@ -7,4 +7,8 @@ SMTP_FROM="no-reply@example.com"
|
||||
|
||||
# telegram-bot
|
||||
TELEGRAM_BOT_TOKEN="get it from https://t.me/BotFather"
|
||||
FUNCTION_SECRET="random secret"
|
||||
FUNCTION_SECRET="random secret"
|
||||
|
||||
# stripe-webhooks
|
||||
STRIPE_API_KEY="<YOUR API KEY HERE>"
|
||||
STRIPE_WEBHOOK_SIGNING_SECRET="<YOUR WEBHOOK SIGNING HERE>"
|
||||
@@ -0,0 +1,21 @@
|
||||
# stripe-webhooks
|
||||
|
||||
Also check out our full Stripe Payments examples for [React Native (Expo)](https://github.com/supabase-community/expo-stripe-payments-with-supabase-functions) and [Flutter](https://github.com/supabase-community/flutter-stripe-payments-with-supabase-functions).
|
||||
|
||||
## Setup env vars
|
||||
|
||||
- `cp supabase/.env.local.example supabase/.env.local`
|
||||
|
||||
## Test locally
|
||||
|
||||
- Terminal 1:
|
||||
- `supabase functions serve --no-verify-jwt stripe-webhooks --env-file ./supabase/.env.local`
|
||||
- Terminal 2:
|
||||
- `stripe listen --forward-to localhost:54321/functions/v1/`
|
||||
- Terminal 3 (optional):
|
||||
- `stripe trigger payment_intent.succeeded`
|
||||
|
||||
## Deploy
|
||||
|
||||
- `supabase functions deploy --no-verify-jwt stripe-webhooks`
|
||||
- `supabase secrets set --env-file ./supabase/.env.local`
|
||||
@@ -0,0 +1,60 @@
|
||||
// Follow this setup guide to integrate the Deno language server with your editor:
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
|
||||
// esm.sh is used to compile stripe-node to be compatible with ES modules.
|
||||
import Stripe from 'https://esm.sh/stripe@9.6.0?target=deno&no-check'
|
||||
|
||||
const stripe = Stripe(Deno.env.get('STRIPE_API_KEY'), {
|
||||
// This is needed to use the Fetch API rather than relying on the Node http
|
||||
// package.
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
})
|
||||
// This is needed in order to use the Web Crypto API in Deno.
|
||||
const cryptoProvider = Stripe.createSubtleCryptoProvider()
|
||||
|
||||
console.log(`Function "stripe-webhooks" up and running!`)
|
||||
|
||||
serve(async (request) => {
|
||||
const signature = request.headers.get('Stripe-Signature')
|
||||
|
||||
// First step is to verify the event. The .text() method must be used as the
|
||||
// verification relies on the raw request body rather than the parsed JSON.
|
||||
const body = await request.text()
|
||||
let receivedEvent
|
||||
try {
|
||||
receivedEvent = await stripe.webhooks.constructEventAsync(
|
||||
body,
|
||||
signature,
|
||||
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET'),
|
||||
undefined,
|
||||
cryptoProvider
|
||||
)
|
||||
} catch (err) {
|
||||
return new Response(err.message, { status: 400 })
|
||||
}
|
||||
console.log(`🔔 Event received: ${receivedEvent.id}`)
|
||||
|
||||
// Secondly, we use this event to query the Stripe API in order to avoid
|
||||
// handling any forged event. If available, we use the idempotency key.
|
||||
const requestOptions =
|
||||
receivedEvent.request && receivedEvent.request.idempotency_key
|
||||
? {
|
||||
idempotencyKey: receivedEvent.request.idempotency_key,
|
||||
}
|
||||
: {}
|
||||
|
||||
let retrievedEvent
|
||||
try {
|
||||
retrievedEvent = await stripe.events.retrieve(receivedEvent.id, requestOptions)
|
||||
} catch (err) {
|
||||
return new Response(err.message, { status: 400 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ id: retrievedEvent.id, status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
@@ -9,12 +9,14 @@ export default function IndexPage() {
|
||||
<div className="w-full h-full bg-gray-300">
|
||||
{!user ? (
|
||||
<div className="w-full h-full flex justify-center items-center p-4">
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
providers={['google', 'github']}
|
||||
socialLayout="horizontal"
|
||||
socialButtonSize="xlarge"
|
||||
/>
|
||||
<div>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
providers={['google', 'github']}
|
||||
socialLayout="horizontal"
|
||||
socialButtonSize="xlarge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -4214,9 +4214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
@@ -17284,9 +17284,9 @@
|
||||
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="
|
||||
},
|
||||
"async": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
|
||||
@@ -4495,9 +4495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
@@ -17949,9 +17949,9 @@
|
||||
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="
|
||||
},
|
||||
"async": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = ui({
|
||||
'../../packages/common/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
'./components/**/*.tsx',
|
||||
'./layouts/**/*.tsx',
|
||||
'./pages/**/*.tsx',
|
||||
'./_blog/*.mdx',
|
||||
// purge styles from supabase ui theme
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Button, Input, IconChevronDown, IconSearch } from '@supabase/ui';
|
||||
import { DropdownControl } from '../../common';
|
||||
import { FilterOperatorOptions } from '../../header/filter';
|
||||
import { Filter, FilterOperator } from '../../../types';
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise';
|
||||
import React, { FC, useState } from 'react'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { Button, Input, IconChevronDown, IconSearch } from '@supabase/ui'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { DropdownControl } from '../../common'
|
||||
import { FilterOperatorOptions } from '../../header/filter'
|
||||
import { Filter, FilterOperator } from '../../../types'
|
||||
|
||||
interface FilterProps {
|
||||
defaultColumnName?: string;
|
||||
defaultValue?: string;
|
||||
foreignColumnNames: string[];
|
||||
onChange: (value: Filter) => void;
|
||||
defaultColumnName?: string
|
||||
defaultValue?: string
|
||||
foreignColumnNames: string[]
|
||||
onChange: (value: Filter) => void
|
||||
}
|
||||
|
||||
export const FilterHeader: FC<FilterProps> = ({
|
||||
@@ -24,46 +25,43 @@ export const FilterHeader: FC<FilterProps> = ({
|
||||
: foreignColumnNames.length > 0
|
||||
? foreignColumnNames[0]
|
||||
: ''
|
||||
);
|
||||
const [condition, setCondition] = useState(FilterOperatorOptions[0].value);
|
||||
const [filterText, setFilterText] = useState(defaultValue ?? '');
|
||||
)
|
||||
const [condition, setCondition] = useState(FilterOperatorOptions[0].value)
|
||||
const [filterText, setFilterText] = useState(defaultValue ?? '')
|
||||
|
||||
const columnOptions =
|
||||
foreignColumnNames.map((x) => {
|
||||
return { value: x, label: x };
|
||||
}) || [];
|
||||
return { value: x, label: x }
|
||||
}) || []
|
||||
|
||||
function triggerOnChange(
|
||||
columnName: string,
|
||||
condition: string,
|
||||
filterText: string
|
||||
) {
|
||||
function triggerOnChange(columnName: string, condition: string, filterText: string) {
|
||||
onFilterChangeDebounced(
|
||||
{
|
||||
id: uuidv4(),
|
||||
column: columnName,
|
||||
operator: condition as FilterOperator,
|
||||
value: filterText,
|
||||
},
|
||||
onChange
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function onColumnChange(value: string | number) {
|
||||
const str = value + '';
|
||||
setColumnName(str);
|
||||
triggerOnChange(str, condition, filterText);
|
||||
const str = value + ''
|
||||
setColumnName(str)
|
||||
triggerOnChange(str, condition, filterText)
|
||||
}
|
||||
|
||||
function onConditionChange(value: string | number) {
|
||||
const str = value + '';
|
||||
setCondition(str);
|
||||
triggerOnChange(columnName, str, filterText);
|
||||
const str = value + ''
|
||||
setCondition(str)
|
||||
triggerOnChange(columnName, str, filterText)
|
||||
}
|
||||
|
||||
function onFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.target.value;
|
||||
setFilterText(value);
|
||||
triggerOnChange(columnName, condition, value);
|
||||
const value = event.target.value
|
||||
setFilterText(value)
|
||||
triggerOnChange(columnName, condition, value)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,12 +74,8 @@ export const FilterHeader: FC<FilterProps> = ({
|
||||
>
|
||||
<Button as="span" type="outline" iconRight={<IconChevronDown />}>
|
||||
<span className="foreign-table-modal__filter__trigger-content">
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">
|
||||
Column
|
||||
</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">
|
||||
{columnName}
|
||||
</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">Column</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">{columnName}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
@@ -93,12 +87,8 @@ export const FilterHeader: FC<FilterProps> = ({
|
||||
>
|
||||
<Button as="span" type="outline" iconRight={<IconChevronDown />}>
|
||||
<span className="foreign-table-modal__filter__trigger-content">
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">
|
||||
Filter
|
||||
</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">
|
||||
{condition}
|
||||
</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">Filter</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">{condition}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
@@ -111,12 +101,12 @@ export const FilterHeader: FC<FilterProps> = ({
|
||||
icon={<IconSearch size="small" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const onFilterChangeDebounced = AwesomeDebouncePromise(
|
||||
(filter: Filter, onChange: (value: Filter) => void) => {
|
||||
onChange(filter);
|
||||
onChange(filter)
|
||||
},
|
||||
500
|
||||
);
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Modal, Menu, IconExternalLink, IconTrash } from '@supabase/ui'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useTrackedState } from '../../../store'
|
||||
import { Dictionary, Filter } from '../../../types'
|
||||
import { RowItem } from './RowItem'
|
||||
@@ -30,6 +31,7 @@ export const ForeignTableModal: React.FC<ForeignTableModalProps> = ({
|
||||
|
||||
if (defaultValue && columnDefinition) {
|
||||
fetchData({
|
||||
id: uuidv4(),
|
||||
column: columnDefinition.targetColumnName!,
|
||||
operator: '=',
|
||||
value: defaultValue,
|
||||
|
||||
@@ -20,7 +20,7 @@ const Header: React.FC<HeaderProps> = ({ onAddColumn, onAddRow, headerActions })
|
||||
const { selectedRows } = state
|
||||
|
||||
return (
|
||||
<div className="bg-scale-100 dark:bg-scale-300 flex h-10 justify-between px-5">
|
||||
<div className="bg-scale-100 dark:bg-scale-300 flex h-10 items-center justify-between px-5">
|
||||
{selectedRows.size > 0 ? (
|
||||
<RowHeader />
|
||||
) : (
|
||||
@@ -126,47 +126,39 @@ const RowHeader: React.FC<RowHeaderProps> = ({}) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ padding: '4px' }}
|
||||
icon={<IconX size="tiny" strokeWidth={2} />}
|
||||
onClick={deselectRows}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text small type="secondary" className="row_header__selected-rows">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="default"
|
||||
style={{ padding: '3px' }}
|
||||
icon={<IconX size="tiny" strokeWidth={2} />}
|
||||
onClick={deselectRows}
|
||||
/>
|
||||
<span className="text-scale-1200 text-xs">
|
||||
{selectedRows.size > 1
|
||||
? `${selectedRows.size} rows selected`
|
||||
: `${selectedRows.size} row selected`}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
// style={{ padding: '4px 8px' }}
|
||||
|
||||
icon={<IconDownload />}
|
||||
onClick={onRowsExportCsv}
|
||||
>
|
||||
Export to csv
|
||||
</Button>
|
||||
{editable && (
|
||||
<>
|
||||
<Divider type="vertical" className="sb-grid-header__inner__divider" light />
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
// style={{ padding: '4px 8px' }}
|
||||
|
||||
icon={<IconTrash size="tiny" />}
|
||||
onClick={onRowsDelete}
|
||||
>
|
||||
{selectedRows.size > 1
|
||||
? `Delete ${selectedRows.size} rows`
|
||||
: `Delete ${selectedRows.size} row`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="primary" size="tiny" icon={<IconDownload />} onClick={onRowsExportCsv}>
|
||||
Export to csv
|
||||
</Button>
|
||||
{editable && (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<IconTrash size="tiny" />}
|
||||
onClick={onRowsDelete}
|
||||
>
|
||||
{selectedRows.size > 1
|
||||
? `Delete ${selectedRows.size} rows`
|
||||
: `Delete ${selectedRows.size} row`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { FC } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { Button, IconPlus, IconFilter, Popover } from '@supabase/ui'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useDispatch, useTrackedState } from '../../../store'
|
||||
import FilterRow from './FilterRow'
|
||||
|
||||
@@ -20,7 +21,6 @@ const FilterPopover: FC = () => {
|
||||
<IconFilter strokeWidth={1.5} />
|
||||
</div>
|
||||
}
|
||||
// style={{ padding: '4px 8px' }}
|
||||
>
|
||||
{btnText}
|
||||
</Button>
|
||||
@@ -37,6 +37,7 @@ const Filter: FC = () => {
|
||||
dispatch({
|
||||
type: 'ADD_FILTER',
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
column: state.table?.columns[0].name,
|
||||
operator: '=',
|
||||
value: '',
|
||||
@@ -47,8 +48,8 @@ const Filter: FC = () => {
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="space-y-2">
|
||||
{state.filters.map((_, index) => (
|
||||
<FilterRow key={`filter-${index}`} filterIdx={index} now={Date.now()} />
|
||||
{state.filters.map((filter, index) => (
|
||||
<FilterRow key={`filter-${filter.id}`} filterIdx={index} now={Date.now()} />
|
||||
))}
|
||||
{state.filters.length == 0 && (
|
||||
<div className="space-y-1 px-3">
|
||||
|
||||
@@ -1,75 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Input, IconChevronDown, IconX } from '@supabase/ui';
|
||||
import { DropdownControl } from '../../common';
|
||||
import { useDispatch, useTrackedState } from '../../../store';
|
||||
import { FilterOperatorOptions } from './Filter.constants';
|
||||
import { updateFilterValueDebounced } from './Filter.utils';
|
||||
import { FC, memo, useState, useEffect, ChangeEvent } from 'react'
|
||||
import { Button, Input, IconChevronDown, IconX } from '@supabase/ui'
|
||||
import { DropdownControl } from '../../common'
|
||||
import { useDispatch, useTrackedState } from '../../../store'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
import { updateFilterValueDebounced } from './Filter.utils'
|
||||
|
||||
/**
|
||||
* use `now` to trigger re-render as filterIdx won't change value
|
||||
* if not filterText state will not updated on delete filter
|
||||
*/
|
||||
type FilterRowProps = {
|
||||
filterIdx: number;
|
||||
now: number;
|
||||
};
|
||||
filterIdx: number
|
||||
now: number
|
||||
}
|
||||
|
||||
const FilterRow: React.FC<FilterRowProps> = ({ filterIdx, now }) => {
|
||||
const state = useTrackedState();
|
||||
const dispatch = useDispatch();
|
||||
const filter = state.filters[filterIdx];
|
||||
const column = state.table?.columns.find((x) => x.name === filter.column);
|
||||
const FilterRow: FC<FilterRowProps> = ({ filterIdx }) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
const filter = state.filters[filterIdx]
|
||||
const column = state.table?.columns.find((x) => x.name === filter.column)
|
||||
const columnOptions =
|
||||
state.table?.columns?.map((x) => {
|
||||
return { value: x.name, label: x.name, postLabel: x.dataType };
|
||||
}) || [];
|
||||
const [filterValue, setFilterValue] = React.useState(filter.value);
|
||||
return { value: x.name, label: x.name, postLabel: x.dataType }
|
||||
}) || []
|
||||
const [filterValue, setFilterValue] = useState(filter.value)
|
||||
|
||||
React.useEffect(() => {
|
||||
const filter = state.filters[filterIdx];
|
||||
setFilterValue(filter.value);
|
||||
}, [filterIdx, now]);
|
||||
useEffect(() => {
|
||||
const filter = state.filters[filterIdx]
|
||||
setFilterValue(filter.value)
|
||||
}, [filterIdx])
|
||||
|
||||
function onColumnChange(column: string | number) {
|
||||
dispatch({
|
||||
type: 'UPDATE_FILTER',
|
||||
payload: { filterIdx, value: { ...filter, column } },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function onOperatorChange(operator: string | number) {
|
||||
dispatch({
|
||||
type: 'UPDATE_FILTER',
|
||||
payload: { filterIdx, value: { ...filter, operator } },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function onFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.target.value;
|
||||
setFilterValue(value);
|
||||
function onFilterChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.target.value
|
||||
setFilterValue(value)
|
||||
updateFilterValueDebounced(
|
||||
{
|
||||
filterIdx,
|
||||
value: { ...filter, value: value },
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function onRemoveFilter() {
|
||||
dispatch({
|
||||
type: 'REMOVE_FILTER',
|
||||
payload: { index: filterIdx },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sb-grid-filter-row px-3">
|
||||
<DropdownControl
|
||||
align="start"
|
||||
options={columnOptions}
|
||||
onSelect={onColumnChange}
|
||||
>
|
||||
<DropdownControl align="start" options={columnOptions} onSelect={onColumnChange}>
|
||||
<Button
|
||||
as="span"
|
||||
type="outline"
|
||||
@@ -83,11 +79,7 @@ const FilterRow: React.FC<FilterRowProps> = ({ filterIdx, now }) => {
|
||||
{column?.name || ''}
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
<DropdownControl
|
||||
align="start"
|
||||
options={FilterOperatorOptions}
|
||||
onSelect={onOperatorChange}
|
||||
>
|
||||
<DropdownControl align="start" options={FilterOperatorOptions} onSelect={onOperatorChange}>
|
||||
<Button
|
||||
as="span"
|
||||
type="outline"
|
||||
@@ -114,6 +106,6 @@ const FilterRow: React.FC<FilterRowProps> = ({ filterIdx, now }) => {
|
||||
onClick={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default React.memo(FilterRow);
|
||||
)
|
||||
}
|
||||
export default memo(FilterRow)
|
||||
@@ -1,23 +1,16 @@
|
||||
import { Dictionary, Filter, FilterOperator, QueryTable, Sort } from '../types';
|
||||
import { IQueryModifier, QueryModifier } from './QueryModifier';
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { Dictionary, Filter, FilterOperator, QueryTable, Sort } from '../types'
|
||||
import { IQueryModifier, QueryModifier } from './QueryModifier'
|
||||
|
||||
export interface IQueryFilter {
|
||||
filter: (
|
||||
column: string,
|
||||
operator: FilterOperator,
|
||||
value: string
|
||||
) => IQueryFilter;
|
||||
match: (criteria: Dictionary<any>) => IQueryFilter;
|
||||
order: (
|
||||
column: string,
|
||||
ascending?: boolean,
|
||||
nullsFirst?: boolean
|
||||
) => IQueryFilter;
|
||||
filter: (column: string, operator: FilterOperator, value: string) => IQueryFilter
|
||||
match: (criteria: Dictionary<any>) => IQueryFilter
|
||||
order: (column: string, ascending?: boolean, nullsFirst?: boolean) => IQueryFilter
|
||||
}
|
||||
|
||||
export class QueryFilter implements IQueryFilter, IQueryModifier {
|
||||
protected filters: Filter[] = [];
|
||||
protected sorts: Sort[] = [];
|
||||
protected filters: Filter[] = []
|
||||
protected sorts: Sort[] = []
|
||||
|
||||
constructor(
|
||||
protected table: QueryTable,
|
||||
@@ -27,15 +20,15 @@ export class QueryFilter implements IQueryFilter, IQueryModifier {
|
||||
) {}
|
||||
|
||||
filter(column: string, operator: FilterOperator, value: any) {
|
||||
this.filters.push({ column, operator, value });
|
||||
return this;
|
||||
this.filters.push({ id: uuidv4(), column, operator, value })
|
||||
return this
|
||||
}
|
||||
|
||||
match(criteria: Dictionary<any>) {
|
||||
Object.entries(criteria).map(([column, value]) => {
|
||||
this.filters.push({ column, operator: '=', value });
|
||||
});
|
||||
return this;
|
||||
this.filters.push({ id: uuidv4(), column, operator: '=', value })
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
order(column: string, ascending = true, nullsFirst = false) {
|
||||
@@ -43,16 +36,16 @@ export class QueryFilter implements IQueryFilter, IQueryModifier {
|
||||
column: column,
|
||||
ascending,
|
||||
nullsFirst,
|
||||
});
|
||||
return this;
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
range(from: number, to: number) {
|
||||
return this._getQueryModifier().range(from, to);
|
||||
return this._getQueryModifier().range(from, to)
|
||||
}
|
||||
|
||||
toSql() {
|
||||
return this._getQueryModifier().toSql();
|
||||
return this._getQueryModifier().toSql()
|
||||
}
|
||||
|
||||
_getQueryModifier() {
|
||||
@@ -61,6 +54,6 @@ export class QueryFilter implements IQueryFilter, IQueryModifier {
|
||||
actionOptions: this.actionOptions,
|
||||
filters: this.filters,
|
||||
sorts: this.sorts,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,116 +1,106 @@
|
||||
import update from 'immutability-helper';
|
||||
import { TOTAL_ROWS_RESET } from '../../constants';
|
||||
import { Filter } from '../../types';
|
||||
import { getInitialFilters } from '../../utils';
|
||||
import { INIT_ACTIONTYPE } from './base';
|
||||
import update from 'immutability-helper'
|
||||
import { TOTAL_ROWS_RESET } from '../../constants'
|
||||
import { Filter } from '../../types'
|
||||
import { getInitialFilters } from '../../utils'
|
||||
import { INIT_ACTIONTYPE } from './base'
|
||||
|
||||
export interface FilterInitialState {
|
||||
filters: Filter[];
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
export const filterInitialState: FilterInitialState = { filters: [] };
|
||||
export const filterInitialState: FilterInitialState = { filters: [] }
|
||||
|
||||
type FILTER_ACTIONTYPE =
|
||||
| INIT_ACTIONTYPE
|
||||
| {
|
||||
type: 'SET_FILTERS';
|
||||
payload: Filter[];
|
||||
type: 'SET_FILTERS'
|
||||
payload: Filter[]
|
||||
}
|
||||
| {
|
||||
type: 'ADD_FILTER';
|
||||
payload: Filter;
|
||||
type: 'ADD_FILTER'
|
||||
payload: Filter
|
||||
}
|
||||
| {
|
||||
type: 'REMOVE_FILTER';
|
||||
payload: { index: number };
|
||||
type: 'REMOVE_FILTER'
|
||||
payload: { index: number }
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_FILTER';
|
||||
type: 'UPDATE_FILTER'
|
||||
payload: {
|
||||
filterIdx: number;
|
||||
value: Filter;
|
||||
};
|
||||
};
|
||||
filterIdx: number
|
||||
value: Filter
|
||||
}
|
||||
}
|
||||
|
||||
const FilterReducer = (
|
||||
state: FilterInitialState,
|
||||
action: FILTER_ACTIONTYPE
|
||||
) => {
|
||||
const FilterReducer = (state: FilterInitialState, action: FILTER_ACTIONTYPE) => {
|
||||
switch (action.type) {
|
||||
case 'INIT_TABLE': {
|
||||
return {
|
||||
...state,
|
||||
filters: getInitialFilters(
|
||||
action.payload.table,
|
||||
action.payload.savedState
|
||||
),
|
||||
};
|
||||
filters: getInitialFilters(action.payload.table, action.payload.savedState),
|
||||
}
|
||||
}
|
||||
case 'SET_FILTERS':
|
||||
return {
|
||||
...state,
|
||||
filters: action.payload,
|
||||
};
|
||||
}
|
||||
case 'ADD_FILTER': {
|
||||
const isValid = isValidFilter(action.payload);
|
||||
const isValid = isValidFilter(action.payload)
|
||||
const newState: any = {
|
||||
...state,
|
||||
filters: update(state.filters, { $push: [action.payload] }),
|
||||
};
|
||||
if (isValid) {
|
||||
newState.page = 1;
|
||||
newState.refreshPageFlag = Date.now();
|
||||
newState.totalRows = TOTAL_ROWS_RESET;
|
||||
}
|
||||
return newState;
|
||||
if (isValid) {
|
||||
newState.page = 1
|
||||
newState.refreshPageFlag = Date.now()
|
||||
newState.totalRows = TOTAL_ROWS_RESET
|
||||
}
|
||||
return newState
|
||||
}
|
||||
case 'REMOVE_FILTER': {
|
||||
const removeIdx = action.payload.index;
|
||||
const removeFilter = state.filters[removeIdx];
|
||||
const isValid = isValidFilter(removeFilter);
|
||||
const removeIdx = action.payload.index
|
||||
const removeFilter = state.filters[removeIdx]
|
||||
|
||||
const isValid = isValidFilter(removeFilter)
|
||||
const newState: any = {
|
||||
...state,
|
||||
filters: update(state.filters, {
|
||||
$splice: [[removeIdx, 1]],
|
||||
}),
|
||||
};
|
||||
if (isValid) {
|
||||
newState.page = 1;
|
||||
newState.refreshPageFlag = Date.now();
|
||||
newState.totalRows = TOTAL_ROWS_RESET;
|
||||
}
|
||||
return newState;
|
||||
|
||||
if (isValid) {
|
||||
newState.page = 1
|
||||
newState.refreshPageFlag = Date.now()
|
||||
newState.totalRows = TOTAL_ROWS_RESET
|
||||
}
|
||||
return newState
|
||||
}
|
||||
case 'UPDATE_FILTER': {
|
||||
const updatedFilter = state.filters[action.payload.filterIdx];
|
||||
const previousIsValid = isValidFilter(updatedFilter);
|
||||
const afterIsValid = isValidFilter(action.payload.value);
|
||||
const updatedFilter = state.filters[action.payload.filterIdx]
|
||||
const previousIsValid = isValidFilter(updatedFilter)
|
||||
const afterIsValid = isValidFilter(action.payload.value)
|
||||
const newState: any = {
|
||||
...state,
|
||||
filters: update(state.filters, {
|
||||
[action.payload.filterIdx]: { $set: action.payload.value },
|
||||
}),
|
||||
};
|
||||
if (previousIsValid || afterIsValid) {
|
||||
newState.page = 1;
|
||||
newState.refreshPageFlag = Date.now();
|
||||
newState.totalRows = TOTAL_ROWS_RESET;
|
||||
}
|
||||
return newState;
|
||||
if (previousIsValid || afterIsValid) {
|
||||
newState.page = 1
|
||||
newState.refreshPageFlag = Date.now()
|
||||
newState.totalRows = TOTAL_ROWS_RESET
|
||||
}
|
||||
return newState
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
return state
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default FilterReducer;
|
||||
export default FilterReducer
|
||||
|
||||
function isValidFilter(filter: Filter) {
|
||||
return (
|
||||
filter &&
|
||||
filter.column &&
|
||||
filter.column != '' &&
|
||||
filter.value &&
|
||||
filter.value != ''
|
||||
);
|
||||
return filter && filter.column && filter.column != '' && filter.value && filter.value != ''
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import {
|
||||
CalculatedColumn,
|
||||
HeaderRendererProps,
|
||||
} from '@supabase/react-data-grid';
|
||||
import { CalculatedColumn, HeaderRendererProps } from '@supabase/react-data-grid'
|
||||
|
||||
export interface Dictionary<T> {
|
||||
[Key: string]: T;
|
||||
[Key: string]: T
|
||||
}
|
||||
|
||||
export interface Sort {
|
||||
column: string;
|
||||
ascending?: boolean;
|
||||
nullsFirst?: boolean;
|
||||
column: string
|
||||
ascending?: boolean
|
||||
nullsFirst?: boolean
|
||||
}
|
||||
|
||||
export type FilterOperator =
|
||||
@@ -25,23 +22,24 @@ export type FilterOperator =
|
||||
| '!~~'
|
||||
| '!~~*'
|
||||
| 'in'
|
||||
| 'is';
|
||||
| 'is'
|
||||
|
||||
export interface Filter {
|
||||
column: string;
|
||||
operator: FilterOperator;
|
||||
value: any;
|
||||
id: string
|
||||
column: string
|
||||
operator: FilterOperator
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface SavedState {
|
||||
filters: Filter[];
|
||||
gridColumns: CalculatedColumn<any, any>[];
|
||||
sorts: Sort[];
|
||||
filters: Filter[]
|
||||
gridColumns: CalculatedColumn<any, any>[]
|
||||
sorts: Sort[]
|
||||
}
|
||||
|
||||
export interface DragItem {
|
||||
index: number;
|
||||
key: string;
|
||||
index: number
|
||||
key: string
|
||||
}
|
||||
|
||||
export type ColumnType =
|
||||
@@ -56,10 +54,10 @@ export type ColumnType =
|
||||
| 'primary_key'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'unknown';
|
||||
| 'unknown'
|
||||
|
||||
export interface ColumnHeaderProps<R> extends HeaderRendererProps<R> {
|
||||
columnType: ColumnType;
|
||||
isPrimaryKey: Boolean | undefined;
|
||||
format: string;
|
||||
columnType: ColumnType
|
||||
isPrimaryKey: Boolean | undefined
|
||||
format: string
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Filter, SavedState, SupaTable } from '../types';
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { Filter, SavedState, SupaTable } from '../types'
|
||||
|
||||
export function getInitialFilters(
|
||||
table: SupaTable,
|
||||
savedState?: SavedState
|
||||
): Filter[] {
|
||||
export function getInitialFilters(table: SupaTable, savedState?: SavedState): Filter[] {
|
||||
if (savedState?.filters) {
|
||||
// verify column still exists
|
||||
const filters = savedState.filters.filter((x) => {
|
||||
const found = table.columns.find((y) => y.name === x.column);
|
||||
return found ? true : false;
|
||||
});
|
||||
if (filters?.length > 0) return filters;
|
||||
const found = table.columns.find((y) => y.name === x.column)
|
||||
return found ? true : false
|
||||
})
|
||||
if (filters?.length > 0) {
|
||||
return filters.map((filter) => ({ ...filter, id: filter?.id || uuidv4() }))
|
||||
}
|
||||
}
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useFlag } from 'hooks'
|
||||
import { IconExternalLink } from '@supabase/ui'
|
||||
|
||||
const AppBannerWrapper: FC = ({ children }) => {
|
||||
const ongoingIncident = useFlag('ongoingIncident')
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{ongoingIncident && (
|
||||
<Link href="https://status.supabase.com">
|
||||
<a target="_blank">
|
||||
<div className="hover:bg-green-1000 flex cursor-pointer items-center justify-center space-x-2 bg-green-900 py-3 transition text-scale-400 dark:text-scale-1200">
|
||||
<p className="text-sm font-medium">
|
||||
We are currently investigating a technical issue, follow status.supabase.com for
|
||||
updates
|
||||
</p>
|
||||
<IconExternalLink size={16} strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppBannerWrapper
|
||||
@@ -1,5 +1,6 @@
|
||||
import PortalToast from './PortalToast'
|
||||
import GoTrueWrapper from './GoTrueWrapper/GoTrueWrapper'
|
||||
import RouteValidationWrapper from './RouteValidationWrapper'
|
||||
import AppBannerWrapper from './AppBannerWrapper'
|
||||
|
||||
export { PortalToast, GoTrueWrapper, RouteValidationWrapper }
|
||||
export { PortalToast, GoTrueWrapper, RouteValidationWrapper, AppBannerWrapper }
|
||||
@@ -1,14 +1,14 @@
|
||||
import { FC, useEffect } from 'react'
|
||||
import { usePrevious } from 'hooks'
|
||||
|
||||
import SqlEditor from 'components/to-be-cleaned/SqlEditor'
|
||||
import SqlEditor from 'components/ui/SqlEditor'
|
||||
|
||||
interface Props {
|
||||
operation: string
|
||||
definition: string
|
||||
check: string
|
||||
onUpdatePolicyUsing: (using: string | null) => void
|
||||
onUpdatePolicyCheck: (check: string | null) => void
|
||||
onUpdatePolicyUsing: (using: string | undefined) => void
|
||||
onUpdatePolicyCheck: (check: string | undefined) => void
|
||||
}
|
||||
|
||||
const PolicyDefinition: FC<Props> = ({
|
||||
@@ -24,8 +24,8 @@ const PolicyDefinition: FC<Props> = ({
|
||||
|
||||
const previousOperation = usePrevious(operation) || ''
|
||||
useEffect(() => {
|
||||
if (showUsing(previousOperation) && !showUsing(operation)) onUpdatePolicyUsing(null)
|
||||
if (showCheck(previousOperation) && !showCheck(operation)) onUpdatePolicyCheck(null)
|
||||
if (showUsing(previousOperation) && !showUsing(operation)) onUpdatePolicyUsing(undefined)
|
||||
if (showCheck(previousOperation) && !showCheck(operation)) onUpdatePolicyCheck(undefined)
|
||||
}, [operation])
|
||||
|
||||
return (
|
||||
|
||||
@@ -68,10 +68,10 @@ const PolicyEditor: FC<Props> = ({
|
||||
operation={operation}
|
||||
definition={definition}
|
||||
check={check}
|
||||
onUpdatePolicyUsing={(definition: string | null) =>
|
||||
onUpdatePolicyUsing={(definition: string | undefined) =>
|
||||
onUpdatePolicyFormFields({ definition })
|
||||
}
|
||||
onUpdatePolicyCheck={(check: string | null) => onUpdatePolicyFormFields({ check })}
|
||||
onUpdatePolicyCheck={(check: string | undefined) => onUpdatePolicyFormFields({ check })}
|
||||
/>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ ON ${schema}.${table}
|
||||
FOR SELECT USING (
|
||||
true
|
||||
);`.trim(),
|
||||
name: 'Enable access to all users',
|
||||
name: 'Enable read access for all users',
|
||||
definition: 'true',
|
||||
check: '',
|
||||
command: 'SELECT',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Modal } from '@supabase/ui'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import SqlEditor from 'components/to-be-cleaned/SqlEditor'
|
||||
import SqlEditor from 'components/ui/SqlEditor'
|
||||
import { PolicyForReview } from './Policies.types'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react'
|
||||
import { Button, Dropdown, IconEdit, IconTrash, IconMoreVertical } from '@supabase/ui'
|
||||
import Panel from 'components/to-be-cleaned/Panel'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useStore } from 'hooks'
|
||||
|
||||
import PolicyTableRowHeader from './PolicyTableRowHeader'
|
||||
import PolicyRow from './PolicyRow'
|
||||
import Panel from 'components/to-be-cleaned/Panel'
|
||||
import Panel from 'components/ui/Panel'
|
||||
|
||||
interface Props {
|
||||
table: PostgresTable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react'
|
||||
import { isEmpty } from 'lodash'
|
||||
import SqlEditor from 'components/to-be-cleaned/SqlEditor'
|
||||
import SqlEditor from 'components/ui/SqlEditor'
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -7,7 +7,6 @@ export const formatComputeSizes = (computeSizes: any[]) => {
|
||||
const microOption = {
|
||||
id: '',
|
||||
name: 'Micro Add-on',
|
||||
description: 'Basic set up to get started',
|
||||
metadata: {
|
||||
default_price_id: undefined,
|
||||
supabase_prod_id: 'addon_instance_micro',
|
||||
@@ -29,30 +28,23 @@ export const formatComputeSizes = (computeSizes: any[]) => {
|
||||
],
|
||||
}
|
||||
|
||||
const addonsOrder = [
|
||||
'addon_instance_small',
|
||||
'addon_instance_medium',
|
||||
'addon_instance_large',
|
||||
'addon_instance_xlarge',
|
||||
'addon_instance_xxlarge',
|
||||
'addon_instance_4xlarge',
|
||||
'addon_instance_8xlarge',
|
||||
'addon_instance_12xlarge',
|
||||
'addon_instance_16xlarge',
|
||||
]
|
||||
|
||||
return [microOption]
|
||||
.concat(
|
||||
computeSizes.filter(
|
||||
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_small'
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
computeSizes.filter(
|
||||
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_medium'
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
computeSizes.filter(
|
||||
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_large'
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
computeSizes.filter(
|
||||
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_xlarge'
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
computeSizes.filter(
|
||||
(option: any) => option.metadata.supabase_prod_id === 'addon_instance_xxlarge'
|
||||
)
|
||||
addonsOrder.map((id: string) => {
|
||||
return computeSizes.find((option: any) => option.metadata.supabase_prod_id === id)
|
||||
})
|
||||
)
|
||||
.filter((option) => option !== undefined)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Badge, Button, Radio } from '@supabase/ui'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
import { useStore, useFlag } from 'hooks'
|
||||
import { getProductPrice } from '../Billing.utils'
|
||||
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
|
||||
|
||||
interface Props {
|
||||
computeSizes: any[]
|
||||
currentComputeSize: any
|
||||
currentComputeSize?: any
|
||||
selectedComputeSize: any
|
||||
onSelectOption: (option: any) => void
|
||||
}
|
||||
@@ -17,10 +19,9 @@ const ComputeSizeSelection: FC<Props> = ({
|
||||
selectedComputeSize,
|
||||
onSelectOption,
|
||||
}) => {
|
||||
// [Joshen] We should have some indication on what's the current compute
|
||||
// size of the project based on currentComputeSize
|
||||
const { ui } = useStore()
|
||||
const projectRef = ui.selectedProjectRef
|
||||
const addonUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -29,49 +30,60 @@ const ComputeSizeSelection: FC<Props> = ({
|
||||
<h4 className="text-lg">Database add-ons</h4>
|
||||
<Badge color="green">Optional</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-scale-1100">
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Choose the database instance size that best fits your needs
|
||||
</p>
|
||||
</div>
|
||||
<Radio.Group type="cards" className="billing-compute-radio">
|
||||
{computeSizes.map((option: any) => {
|
||||
const defaultPrice = getProductPrice(option)
|
||||
return (
|
||||
<Radio
|
||||
hidden
|
||||
key={option.id}
|
||||
align="vertical"
|
||||
label={option.name}
|
||||
// @ts-ignore
|
||||
description={
|
||||
<div>
|
||||
<p>{option.description}</p>
|
||||
<p>{option.metadata.features}</p>
|
||||
</div>
|
||||
}
|
||||
value={option.id}
|
||||
optionalLabel={<div>${defaultPrice.unit_amount / 100} / month</div>}
|
||||
checked={selectedComputeSize?.id === option.id}
|
||||
onChange={() => onSelectOption(option)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Radio
|
||||
hidden
|
||||
disabled
|
||||
key="compute-size-cta"
|
||||
align="vertical"
|
||||
label="Need a larger add on?"
|
||||
description="Reach out to us - we've got you covered!"
|
||||
optionalLabel={
|
||||
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
|
||||
<a>
|
||||
<Button>Contact us</Button>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Radio.Group>
|
||||
{addonUpdateDisabled ? (
|
||||
<DisabledWarningDueToIncident title="Updating database add-ons is currently disabled" />
|
||||
) : (
|
||||
<Radio.Group type="cards" className="billing-compute-radio">
|
||||
{computeSizes.map((option: any) => {
|
||||
const defaultPrice = getProductPrice(option)
|
||||
return (
|
||||
<Radio
|
||||
hidden
|
||||
key={option.id}
|
||||
align="vertical"
|
||||
// @ts-ignore
|
||||
label={
|
||||
<div className="flex items-center space-x-4">
|
||||
<p>{option.name}</p>
|
||||
{currentComputeSize?.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={selectedComputeSize?.id === option.id}
|
||||
onChange={() => onSelectOption(option)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Radio
|
||||
hidden
|
||||
disabled
|
||||
key="compute-size-cta"
|
||||
align="vertical"
|
||||
label="Need a larger add on?"
|
||||
description="Reach out to us - we've got you covered!"
|
||||
optionalLabel={
|
||||
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
|
||||
<a>
|
||||
<Button>Contact us</Button>
|
||||
</a>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,21 @@ export const getProductPrice = (product: any) => {
|
||||
: product.prices[0]
|
||||
return price
|
||||
}
|
||||
|
||||
export const formSubscriptionUpdatePayload = (
|
||||
selectedTier: any,
|
||||
selectedComputeSize: any,
|
||||
selectedPaymentMethod: string,
|
||||
region: string
|
||||
) => {
|
||||
const defaultPrice = selectedTier ? getProductPrice(selectedTier) : undefined
|
||||
const addons =
|
||||
region === 'af-south-1' || !selectedComputeSize.id ? [] : [selectedComputeSize.prices[0].id]
|
||||
const proration_date = Math.floor(Date.now() / 1000)
|
||||
return {
|
||||
...(defaultPrice && { tier: defaultPrice.id }),
|
||||
addons,
|
||||
proration_date,
|
||||
payment_method: selectedPaymentMethod,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Transition } from '@headlessui/react'
|
||||
|
||||
import { useStore } 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 { formatComputeSizes } from './AddOns/AddOns.utils'
|
||||
import {
|
||||
AddNewPaymentMethodModal,
|
||||
ComputeSizeSelection,
|
||||
StripeSubscription,
|
||||
PaymentSummaryPanel,
|
||||
UpdateSuccess,
|
||||
} from './'
|
||||
import { SubscriptionPreview } from './Billing.types'
|
||||
import { formSubscriptionUpdatePayload } from './Billing.utils'
|
||||
|
||||
interface Props {
|
||||
products: { tiers: any[]; addons: any[] }
|
||||
paymentMethods?: any[]
|
||||
currentSubscription: StripeSubscription
|
||||
isLoadingPaymentMethods: boolean
|
||||
}
|
||||
|
||||
const EnterpriseUpdate: FC<Props> = ({
|
||||
products,
|
||||
paymentMethods,
|
||||
currentSubscription,
|
||||
isLoadingPaymentMethods,
|
||||
}) => {
|
||||
const { app, ui } = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const projectId = ui.selectedProject?.id ?? -1
|
||||
const projectRef = ui.selectedProject?.ref ?? 'default'
|
||||
const projectRegion = ui.selectedProject?.region ?? ''
|
||||
|
||||
const { addons } = products
|
||||
const computeSizes = formatComputeSizes(addons)
|
||||
const currentComputeSize =
|
||||
computeSizes.find((option: any) => option.id === currentSubscription?.addons[0]?.prod_id) ||
|
||||
computeSizes.find((option: any) => option.metadata.supabase_prod_id === 'addon_instance_micro')
|
||||
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<any>()
|
||||
const [selectedComputeSize, setSelectedComputeSize] = useState<any>(currentComputeSize)
|
||||
const [subscriptionPreview, setSubscriptionPreview] = useState<SubscriptionPreview>()
|
||||
|
||||
const [isRefreshingPreview, setIsRefreshingPreview] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccessful, setIsSuccessful] = useState(false)
|
||||
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
|
||||
|
||||
const isChangingComputeSize = currentComputeSize?.id !== selectedComputeSize.id
|
||||
|
||||
useEffect(() => {
|
||||
getSubscriptionPreview()
|
||||
}, [selectedComputeSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingPaymentMethods && paymentMethods && paymentMethods.length > 0) {
|
||||
setSelectedPaymentMethod(paymentMethods[0].id)
|
||||
}
|
||||
}, [isLoadingPaymentMethods, paymentMethods])
|
||||
|
||||
const getSubscriptionPreview = async () => {
|
||||
// For enterprise, only tier will be fixed based on current subscription
|
||||
// Only allow add-ons changing
|
||||
const payload = {
|
||||
...formSubscriptionUpdatePayload(
|
||||
null,
|
||||
selectedComputeSize,
|
||||
selectedPaymentMethod,
|
||||
projectRegion
|
||||
),
|
||||
tier: currentSubscription.tier.price_id,
|
||||
}
|
||||
|
||||
setIsRefreshingPreview(true)
|
||||
const preview = await post(`${API_URL}/projects/${projectRef}/subscription/preview`, payload)
|
||||
if (preview.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to fetch subscription preview: ${preview.error.message}`,
|
||||
})
|
||||
}
|
||||
setSubscriptionPreview(preview)
|
||||
setIsRefreshingPreview(false)
|
||||
}
|
||||
|
||||
// Last todo to support enterprise billing on dashboard + E2E test
|
||||
const onConfirmPayment = async () => {
|
||||
const payload = {
|
||||
...formSubscriptionUpdatePayload(
|
||||
null,
|
||||
selectedComputeSize,
|
||||
selectedPaymentMethod,
|
||||
projectRegion
|
||||
),
|
||||
tier: currentSubscription.tier.price_id,
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
const res = await patch(`${API_URL}/projects/${projectRef}/subscription`, payload)
|
||||
if (res?.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update subscription: ${res?.error?.message}`,
|
||||
})
|
||||
} else {
|
||||
if (isChangingComputeSize) {
|
||||
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message:
|
||||
'Your project has been updated and is currently restarting to update its instance size',
|
||||
duration: 8000,
|
||||
})
|
||||
router.push(`/project/${projectRef}`)
|
||||
} else {
|
||||
setIsSuccessful(true)
|
||||
}
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
if (!isSubmitting && isSuccessful) {
|
||||
return (
|
||||
<UpdateSuccess
|
||||
projectRef={projectRef || ''}
|
||||
title="Your project has been updated!"
|
||||
message="Let us know if you have any feedback at sales@supabase.io."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
show
|
||||
enter="transition ease-out duration-300"
|
||||
enterFrom="transform opacity-0 translate-x-10"
|
||||
enterTo="transform opacity-100 translate-x-0"
|
||||
className="flex w-full items-start justify-between"
|
||||
>
|
||||
<div className="2xl:min-w-5xl mx-auto mt-10">
|
||||
<div className="relative space-y-4 px-5">
|
||||
<div className="space-y-8">
|
||||
<h4 className="text-scale-900 text-lg">Change your project's subscription</h4>
|
||||
<div
|
||||
className="space-y-8 overflow-scroll pb-8"
|
||||
style={{ height: 'calc(100vh - 6.4rem - 57px)' }}
|
||||
>
|
||||
<h3 className="text-xl">
|
||||
Managing your <span className="text-brand-900">Enterprise</span> plan
|
||||
</h3>
|
||||
<div
|
||||
className={[
|
||||
'bg-panel-body-light dark:bg-panel-body-dark border-panel-border-light border-panel-border-dark',
|
||||
'flex max-w-[600px] items-center justify-between gap-16 rounded border px-6 py-4 drop-shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<p>
|
||||
If you'd like to change your subscription away from enterprise, please reach out
|
||||
to <span className="text-brand-900">enterprise@supabase.io</span> with your
|
||||
request.
|
||||
</p>
|
||||
</div>
|
||||
{projectRegion !== 'af-south-1' && (
|
||||
<>
|
||||
<Divider light />
|
||||
<ComputeSizeSelection
|
||||
computeSizes={computeSizes || []}
|
||||
currentComputeSize={currentComputeSize}
|
||||
selectedComputeSize={selectedComputeSize}
|
||||
onSelectOption={setSelectedComputeSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[32rem]">
|
||||
<PaymentSummaryPanel
|
||||
isSpendCapEnabled={true}
|
||||
isSubmitting={isSubmitting}
|
||||
isRefreshingPreview={isRefreshingPreview}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
currentPlan={currentSubscription.tier}
|
||||
currentComputeSize={currentComputeSize}
|
||||
selectedComputeSize={selectedComputeSize}
|
||||
paymentMethods={paymentMethods}
|
||||
isLoadingPaymentMethods={isLoadingPaymentMethods}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
onSelectAddNewPaymentMethod={() => {
|
||||
setShowAddPaymentMethodModal(true)
|
||||
}}
|
||||
onConfirmPayment={onConfirmPayment}
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/project/${projectRef}/settings/billing/update/pro`}
|
||||
onCancel={() => setShowAddPaymentMethodModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnterpriseUpdate
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
Alert,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import { useFlag, useStore } from 'hooks'
|
||||
import { STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { useStore } from 'hooks'
|
||||
import { PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { SubscriptionPreview } from '../Billing.types'
|
||||
import { getProductPrice } from '../Billing.utils'
|
||||
import PaymentTotal from './PaymentTotal'
|
||||
@@ -57,21 +57,23 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
onConfirmPayment,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
/**
|
||||
* Feature flags
|
||||
*/
|
||||
const nativeBilling = useFlag('nativeBilling')
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
|
||||
const { ui } = useStore()
|
||||
const projectRegion = ui.selectedProject?.region
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
|
||||
const isEnterprise = currentPlan.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
|
||||
const isChangingPlan =
|
||||
(currentPlan.prod_id !== STRIPE_PRODUCT_IDS.PAYG && currentPlan.prod_id !== selectedPlan?.id) ||
|
||||
(currentPlan.prod_id !== STRIPE_PRODUCT_IDS.PAYG &&
|
||||
selectedPlan &&
|
||||
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 hasChangesToPlan = subscriptionPreview?.has_changes ?? false
|
||||
|
||||
// If it's enterprise we only only changing of add-ons
|
||||
const hasChangesToPlan = isEnterprise
|
||||
? isChangingComputeSize
|
||||
: subscriptionPreview?.has_changes ?? false
|
||||
|
||||
const getPlanName = (plan: any) => {
|
||||
if (plan.prod_id === STRIPE_PRODUCT_IDS.PAYG || plan.id === STRIPE_PRODUCT_IDS.PAYG) {
|
||||
@@ -82,14 +84,14 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="bg-panel-body-light dark:bg-panel-body-dark w-full px-6 lg:px-12 py-10 space-y-8 border-l"
|
||||
className="bg-panel-body-light dark:bg-panel-body-dark w-full space-y-8 border-l px-6 py-10 lg:px-12"
|
||||
style={{ height: 'calc(100vh - 57px)' }}
|
||||
>
|
||||
<p>Payment Summary</p>
|
||||
|
||||
{/* Subscription details */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-scale-1100">Selected subscription</p>
|
||||
<p className="text-scale-1100 text-sm">Selected subscription</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`${isChangingPlan ? 'text-scale-1100 line-through' : ''} text-sm`}>
|
||||
{getPlanName(currentPlan)}
|
||||
@@ -109,57 +111,53 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
</div>
|
||||
|
||||
{/* Add on details */}
|
||||
{nativeBilling && (
|
||||
<>
|
||||
{projectRegion !== 'af-south-1' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">Selected add-ons</p>
|
||||
{currentComputeSize === undefined && selectedComputeSize === undefined && (
|
||||
<p className="text-scale-1100 text-sm">No add-ons selected</p>
|
||||
)}
|
||||
{currentComputeSize !== undefined && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={`${
|
||||
isChangingComputeSize ? 'text-scale-1100 line-through' : ''
|
||||
} text-sm`}
|
||||
>
|
||||
{currentComputeSize.name}
|
||||
</p>
|
||||
<p
|
||||
className={`${
|
||||
isChangingComputeSize ? 'text-scale-1100 line-through' : ''
|
||||
} text-sm`}
|
||||
>
|
||||
${(getProductPrice(currentComputeSize).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">
|
||||
${(getProductPrice(selectedComputeSize).unit_amount / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isChangingComputeSize && (
|
||||
<div className="!mt-4">
|
||||
<InformationBox
|
||||
hideCollapse
|
||||
defaultVisibility
|
||||
icon={<IconAlertCircle strokeWidth={2} />}
|
||||
title="Changing your compute size"
|
||||
description="It will take up to 2 minutes for changes to take place, and your project will be unavailable during that time"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{projectRegion !== 'af-south-1' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">Selected add-ons</p>
|
||||
{currentComputeSize === undefined && selectedComputeSize === undefined && (
|
||||
<p className="text-scale-1100 text-sm">No add-ons selected</p>
|
||||
)}
|
||||
{currentComputeSize !== undefined && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={`${
|
||||
isChangingComputeSize ? 'text-scale-1100 line-through' : ''
|
||||
} text-sm`}
|
||||
>
|
||||
{currentComputeSize.name}
|
||||
</p>
|
||||
<p
|
||||
className={`${
|
||||
isChangingComputeSize ? 'text-scale-1100 line-through' : ''
|
||||
} text-sm`}
|
||||
>
|
||||
${(getProductPrice(currentComputeSize).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">
|
||||
${(getProductPrice(selectedComputeSize).unit_amount / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isChangingComputeSize && (
|
||||
<div className="!mt-4">
|
||||
<InformationBox
|
||||
hideCollapse
|
||||
defaultVisibility
|
||||
icon={<IconAlertCircle strokeWidth={2} />}
|
||||
title="Changing your compute size"
|
||||
description="It will take up to 2 minutes for changes to take place, and your project will be unavailable during that time"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px w-full bg-scale-600" />
|
||||
<div className="bg-scale-600 h-px w-full" />
|
||||
|
||||
<PaymentTotal
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
@@ -171,13 +169,13 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Select payment method</p>
|
||||
{isLoadingPaymentMethods ? (
|
||||
<div className="flex items-center rounded-md space-x-4 px-4 py-2 bg-scale-400 border border-scale-700">
|
||||
<div className="bg-scale-400 border-scale-700 flex items-center space-x-4 rounded-md border px-4 py-2">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm text-scale-1100">Retrieving payment methods</p>
|
||||
<p className="text-scale-1100 text-sm">Retrieving payment methods</p>
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="flex items-center justify-between rounded-md px-4 py-2 bg-scale-100 border border-dashed">
|
||||
<div className="flex items-center space-x-4 text-scale-1100">
|
||||
<div className="bg-scale-100 flex items-center justify-between rounded-md border border-dashed px-4 py-2">
|
||||
<div className="text-scale-1100 flex items-center space-x-4">
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No saved payment methods</p>
|
||||
</div>
|
||||
@@ -214,11 +212,11 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="py-2 px-3 flex items-center space-x-2 cursor-pointer transition group hover:bg-scale-500"
|
||||
className="hover:bg-scale-500 group flex cursor-pointer items-center space-x-2 py-2 px-3 transition"
|
||||
onClick={onSelectAddNewPaymentMethod}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
<p className="text-scale-1000 transition group-hover:text-scale-1200">
|
||||
<p className="text-scale-1000 group-hover:text-scale-1200 transition">
|
||||
Add new payment method
|
||||
</p>
|
||||
</div>
|
||||
@@ -247,7 +245,7 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
header="Updating project database instance size"
|
||||
onCancel={() => setShowConfirmModal(false)}
|
||||
>
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="space-y-4 py-4">
|
||||
<Modal.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
@@ -259,7 +257,7 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
<Modal.Content>
|
||||
<p className="text-sm text-scale-1200">Would you like to update your project now?</p>
|
||||
<p className="text-scale-1200 text-sm">Would you like to update your project now?</p>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FC } from 'react'
|
||||
import { Button, IconLoader } from '@supabase/ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
import { useFlag } from 'hooks'
|
||||
import { STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { StripeProduct } from '../..'
|
||||
import { StripeProduct } from 'components/interfaces/Billing'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +14,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const PlanCTAButton: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
|
||||
const getButtonType = (plan: any) => {
|
||||
if (plan.name === 'Enterprise' || plan.id === STRIPE_PRODUCT_IDS.FREE) {
|
||||
return 'default'
|
||||
@@ -71,9 +75,9 @@ const PlanCTAButton: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<Button
|
||||
disabled={disabled}
|
||||
disabled={disabled || projectUpdateDisabled}
|
||||
type={type}
|
||||
onClick={() => onSelectPlan(plan)}
|
||||
block
|
||||
@@ -81,6 +85,11 @@ const PlanCTAButton: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
{!disabled && projectUpdateDisabled && (
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Subscription changes are currently disabled, our engineers are working on a fix
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||