feat: upgrade via pricing page (#28942)

* feat: upgrade via pricing page

* reinstall cmdk

* fix button size

* improve search & autoclose

* Update apps/www/lib/fetchWrapper.ts

Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>

* Update apps/www/lib/fetchWrapper.ts

Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>

* update telemetry events

---------

Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
This commit is contained in:
Alaister Young
2024-08-29 05:43:00 +08:00
committed by GitHub
parent 52ea41bf6e
commit bee86a9c63
11 changed files with 499 additions and 166 deletions

View File

@@ -1,39 +1,8 @@
import type { Session, User } from '@supabase/auth-js'
import { gotrueClient } from 'common'
import type { User } from '@supabase/auth-js'
import { getAccessToken, gotrueClient } from 'common'
export const auth = gotrueClient
let currentSession: Session | null = null
auth.onAuthStateChange((event, session) => {
currentSession = session
})
/**
* Grabs the currently available access token, or calls getSession.
*/
export async function getAccessToken() {
// ignore if server-side
if (typeof window === 'undefined') return undefined
const aboutToExpire = currentSession?.expires_at
? currentSession.expires_at - Math.ceil(Date.now() / 1000) < 30
: false
if (!currentSession || aboutToExpire) {
const {
data: { session },
error,
} = await auth.getSession()
if (error) {
throw error
}
return session?.access_token
}
return currentSession.access_token
}
export { getAccessToken }
export const getAuthUser = async (token: String): Promise<any> => {
try {

View File

@@ -62,7 +62,7 @@
"ip-num": "^1.5.1",
"json-logic-js": "^2.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.338.0",
"lucide-react": "^0.436.0",
"markdown-table": "^3.0.3",
"mime-db": "^1.53.0",
"mobx": "^6.10.2",

View File

@@ -1,59 +1,74 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useTelemetryProps } from 'common/hooks/useTelemetryProps'
import { plans } from 'shared-data/plans'
import { pricing } from 'shared-data/pricing'
import { Button, Select, cn } from 'ui'
import { PricingTableRowDesktop, PricingTableRowMobile } from '~/components/Pricing/PricingTableRow'
import Telemetry, { TelemetryEvent } from '~/lib/telemetry'
import { useTelemetryProps } from 'common/hooks/useTelemetryProps'
import gaEvents from '~/lib/gaEvents'
import { Organization } from '~/data/organizations'
import Solutions from '~/data/Solutions'
import { pricing } from 'shared-data/pricing'
import { plans } from 'shared-data/plans'
import gaEvents from '~/lib/gaEvents'
import Telemetry, { TelemetryEvent } from '~/lib/telemetry'
import UpgradePlan from './UpgradePlan'
const PricingComparisonTable = () => {
const MobileHeader = ({
description,
priceDescription,
price,
plan,
showDollarSign = true,
from = false,
organizations,
hasExistingOrganizations,
}: {
description: string
priceDescription: string
price: string
plan: string
showDollarSign?: boolean
from?: boolean
organizations?: Organization[]
hasExistingOrganizations?: boolean
}) => {
const router = useRouter()
const telemetryProps = useTelemetryProps()
const [activeMobilePlan, setActiveMobilePlan] = useState('Free')
const sendTelemetryEvent = async (event: TelemetryEvent) => {
await Telemetry.sendEvent(event, telemetryProps, router)
}
const MobileHeader = ({
description,
priceDescription,
price,
plan,
showDollarSign = true,
from = false,
}: {
description: string
priceDescription: string
price: string
plan: string
showDollarSign?: boolean
from?: boolean
}) => {
const selectedPlan = plans.find((p) => p.name === plan)!
const selectedPlan = plans.find((p) => p.name === plan)!
const isUpgradablePlan = selectedPlan.name === 'Pro' || selectedPlan.name === 'Team'
return (
<div className="mt-8 px-4 mobile-header">
<h2 className="text-foreground text-3xl font-medium uppercase font-mono">{plan}</h2>
<div className="flex items-baseline gap-2">
{from && <span className="text-foreground text-base">From</span>}
{showDollarSign ? (
<span className="h1 font-mono">
{plan !== 'Enterprise' ? '$' : ''}
{price}
</span>
) : (
<span className="text-foreground-light">{price}</span>
)}
return (
<div className="mt-8 px-4 mobile-header">
<h2 className="text-foreground text-3xl font-medium uppercase font-mono">{plan}</h2>
<div className="flex items-baseline gap-2">
{from && <span className="text-foreground text-base">From</span>}
{showDollarSign ? (
<span className="h1 font-mono">
{plan !== 'Enterprise' ? '$' : ''}
{price}
</span>
) : (
<span className="text-foreground-light">{price}</span>
)}
<p className="p">{priceDescription}</p>
</div>
<p className="p">{description}</p>
<p className="p">{priceDescription}</p>
</div>
<p className="p">{description}</p>
{isUpgradablePlan && hasExistingOrganizations ? (
<UpgradePlan
organizations={organizations}
onClick={() =>
sendTelemetryEvent(
gaEvents[`www_pricing_comparison_${plan.toLowerCase()}_mobile_upgrade`]
)
}
size="medium"
/>
) : (
<Button asChild size="medium" type={plan === 'Enterprise' ? 'default' : 'primary'} block>
<Link
href={selectedPlan.href}
@@ -64,8 +79,26 @@ const PricingComparisonTable = () => {
{selectedPlan.cta}
</Link>
</Button>
</div>
)
)}
</div>
)
}
interface PricingComparisonTableProps {
organizations?: Organization[]
hasExistingOrganizations?: boolean
}
const PricingComparisonTable = ({
organizations,
hasExistingOrganizations,
}: PricingComparisonTableProps) => {
const router = useRouter()
const telemetryProps = useTelemetryProps()
const [activeMobilePlan, setActiveMobilePlan] = useState('Free')
const sendTelemetryEvent = async (event: TelemetryEvent) => {
await Telemetry.sendEvent(event, telemetryProps, router)
}
return (
@@ -161,6 +194,8 @@ const PricingComparisonTable = () => {
price={'25'}
priceDescription={'/month + additional use'}
description={'Everything you need to scale your project into production'}
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
<PricingTableRowMobile
category={pricing.database}
@@ -213,6 +248,8 @@ const PricingComparisonTable = () => {
price={'599'}
priceDescription={'/month + additional use'}
description={'Collaborate with different permissions and access patterns'}
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
<PricingTableRowMobile
category={pricing.database}
@@ -327,54 +364,72 @@ const PricingComparisonTable = () => {
/>
</th>
{plans.map((plan) => (
<th
className="text-foreground w-1/4 px-0 text-left text-sm font-normal"
scope="col"
key={plan.name}
>
<span className="flex flex-col px-6 pr-2 pt-2 gap-1.5">
<span className="flex flex-col xl:flex-row xl:items-end gap-1">
<h3 className="text-lg xl:text-xl 2xl:text-2xl leading-5 uppercase font-mono font-normal flex items-center">
{plan.name}
</h3>
<p
className={cn(
'text-foreground-lighter -my-1 xl:m-0',
plan.name === 'Enterprise' && 'xl:opacity-0'
)}
>
<span className="text-foreground-lighter font-mono text-xl mr-1 tracking-tighter">
{plan.name !== 'Enterprise' && '$'}
{plan.priceMonthly}
</span>
{['Free', 'Pro', 'Team'].includes(plan.name) && (
<span className="text-[13px] leading-4 mt-1">{plan.costUnit}</span>
)}
</p>
</span>
<span className="flex flex-col justify-between h-full pb-2">
<Button
asChild
size="tiny"
type={plan.name === 'Enterprise' ? 'default' : 'primary'}
block
>
<Link
href={plan.href}
onClick={() =>
sendTelemetryEvent(
gaEvents[`www_pricing_comparison_${plan.name.toLowerCase()}`]
)
}
{plans.map((plan) => {
const isUpgradablePlan = plan.name === 'Pro' || plan.name === 'Team'
return (
<th
className="text-foreground w-1/4 px-0 text-left text-sm font-normal"
scope="col"
key={plan.name}
>
<span className="flex flex-col px-6 pr-2 pt-2 gap-1.5">
<span className="flex flex-col xl:flex-row xl:items-end gap-1">
<h3 className="text-lg xl:text-xl 2xl:text-2xl leading-5 uppercase font-mono font-normal flex items-center">
{plan.name}
</h3>
<p
className={cn(
'text-foreground-lighter -my-1 xl:m-0',
plan.name === 'Enterprise' && 'xl:opacity-0'
)}
>
{plan.cta}
</Link>
</Button>
<span className="text-foreground-lighter font-mono text-xl mr-1 tracking-tighter">
{plan.name !== 'Enterprise' && '$'}
{plan.priceMonthly}
</span>
{['Free', 'Pro', 'Team'].includes(plan.name) && (
<span className="text-[13px] leading-4 mt-1">{plan.costUnit}</span>
)}
</p>
</span>
<span className="flex flex-col justify-between h-full pb-2">
{isUpgradablePlan && hasExistingOrganizations ? (
<UpgradePlan
organizations={organizations}
onClick={() =>
sendTelemetryEvent(
gaEvents[
`www_pricing_comparison_${plan.name.toLowerCase()}_upgrade`
]
)
}
size="tiny"
/>
) : (
<Button
asChild
size="tiny"
type={plan.name === 'Enterprise' ? 'default' : 'primary'}
block
>
<Link
href={plan.href}
onClick={() =>
sendTelemetryEvent(
gaEvents[`www_pricing_comparison_${plan.name.toLowerCase()}`]
)
}
>
{plan.cta}
</Link>
</Button>
)}
</span>
</span>
</span>
</th>
))}
</th>
)
})}
</tr>
</thead>
<tbody className="border-default divide-border divide-y first:divide-y-0">

View File

@@ -1,14 +1,20 @@
import React, { FC } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Button, cn, IconCheck } from 'ui'
import Telemetry, { TelemetryEvent } from '~/lib/telemetry'
import { useTelemetryProps } from 'common/hooks/useTelemetryProps'
import gaEvents from '~/lib/gaEvents'
import { pickFeatures, pickFooter, plans } from 'shared-data/plans'
import { Button, cn, IconCheck } from 'ui'
import { Organization } from '~/data/organizations'
import gaEvents from '~/lib/gaEvents'
import Telemetry, { TelemetryEvent } from '~/lib/telemetry'
import UpgradePlan from './UpgradePlan'
const PricingPlans: FC = () => {
interface PricingPlansProps {
organizations?: Organization[]
hasExistingOrganizations?: boolean
}
const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansProps) => {
const router = useRouter()
const telemetryProps = useTelemetryProps()
@@ -21,24 +27,29 @@ const PricingPlans: FC = () => {
<div className="relative z-10 mx-auto w-full px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-md grid lg:max-w-none lg:grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-0">
{plans.map((plan) => {
const isPromoPlan = plan.name === 'Pro'
const isProPlan = plan.name === 'Pro'
const isTeamPlan = plan.name === 'Team'
const isUpgradablePlan = isProPlan || isTeamPlan
const features = pickFeatures(plan)
const footer = pickFooter(plan)
const sendPricingEvent = () => {
sendTelemetryEvent(gaEvents[`www_pricing_hero_plan_${plan.name.toLowerCase()}`])
}
return (
<div
key={`row-${plan.name}`}
className={cn(
'flex flex-col border xl:border-r-0 last:border-r bg-surface-75 rounded-xl xl:rounded-none first:rounded-l-xl last:rounded-r-xl',
isPromoPlan && 'border-foreground-muted !border-2 !rounded-xl xl:-my-8',
isProPlan && 'border-foreground-muted !border-2 !rounded-xl xl:-my-8',
isTeamPlan && 'xl:border-l-0'
)}
>
<div
className={cn(
'px-8 xl:px-4 2xl:px-8 pt-6',
isPromoPlan ? 'rounded-tr-[9px] rounded-tl-[9px]' : ''
isProPlan ? 'rounded-tr-[9px] rounded-tl-[9px]' : ''
)}
>
<div className="flex items-center gap-2">
@@ -56,28 +67,25 @@ const PricingPlans: FC = () => {
<p
className={cn(
'text-foreground-light mb-4 text-sm 2xl:pr-4',
isPromoPlan && 'xl:mb-12'
isProPlan && 'xl:mb-12'
)}
>
{plan.description}
</p>
<Button
block
size="large"
type={plan.name === 'Enterprise' ? 'default' : 'primary'}
asChild
>
<Link
href={plan.href}
onClick={() =>
sendTelemetryEvent(
gaEvents[`www_pricing_hero_plan_${plan.name.toLowerCase()}`]
)
}
{isUpgradablePlan && hasExistingOrganizations ? (
<UpgradePlan organizations={organizations} onClick={sendPricingEvent} />
) : (
<Button
block
size="large"
type={plan.name === 'Enterprise' ? 'default' : 'primary'}
asChild
>
{plan.cta}
</Link>
</Button>
<Link href={plan.href} onClick={sendPricingEvent}>
{plan.cta}
</Link>
</Button>
)}
<div
className={cn(
@@ -135,7 +143,7 @@ const PricingPlans: FC = () => {
<div
className={cn(
'border-default flex rounded-bl-[4px] rounded-br-[4px] flex-1 flex-col px-8 xl:px-4 2xl:px-8 py-6',
isPromoPlan && 'mb-0.5 rounded-bl-[4px] rounded-br-[4px]'
isProPlan && 'mb-0.5 rounded-bl-[4px] rounded-br-[4px]'
)}
>
{plan.preface && (

View File

@@ -0,0 +1,158 @@
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import {
Button,
ButtonProps,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
DialogTrigger,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
import { Organization } from '~/data/organizations'
interface UpgradePlanProps {
organizations?: Organization[]
onClick?: () => void
size?: ButtonProps['size']
}
const UpgradePlan = ({ organizations = [], onClick, size = 'large' }: UpgradePlanProps) => {
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
return (
<Dialog>
<DialogTrigger asChild>
<Button block size={size} type="primary" onClick={onClick}>
Upgrade now
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="pb-3">
<DialogTitle>Upgrade organization</DialogTitle>
<DialogDescription>
Choose the organization you want to upgrade to a paid plan.
</DialogDescription>
</DialogHeader>
<DialogSection className="py-2">
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="default"
role="combobox"
size={'small'}
aria-expanded={open}
className="w-full justify-between"
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
>
{value === 'new-organization' ? (
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
New organization
</span>
) : value ? (
organizations.find((organization) => organization.slug === value)?.name
) : (
'Select an organization...'
)}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[300px] p-0">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Select organization..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No organizations found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{organizations.map((organization) => (
<CommandItem_Shadcn_
key={organization.slug}
value={organization.slug}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue)
setOpen(false)
}}
keywords={[organization.name]}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === organization.slug ? 'opacity-100' : 'opacity-0'
)}
/>
{organization.name}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
value="new-organization"
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue)
setOpen(false)
}}
keywords={['Create a new organization']}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === 'new-organization' ? 'opacity-100' : 'opacity-0'
)}
/>
<Plus className="h-4 w-4 mr-2" /> Create a new organization
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</DialogSection>
<DialogSection>
<DialogDescription className="text-xs">
Upon continuing, you will be redirected to the organization&apos;s billing page where
you can upgrade to a paid plan.
</DialogDescription>
</DialogSection>
<DialogFooter>
<DialogClose asChild>
<Button type="outline">Cancel</Button>
</DialogClose>
<Button disabled={!value} asChild>
<Link
href={
value === 'new-organization'
? `/dashboard/new`
: `/dashboard/org/${value}/billing?panel=subscriptionPlan`
}
>
Continue
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default UpgradePlan

View File

@@ -0,0 +1,47 @@
import { components } from 'api-types'
import { useEffect, useState } from 'react'
import { API_URL } from '~/lib/constants'
import { get } from '~/lib/fetchWrapper'
export type Organization = components['schemas']['OrganizationResponse']
export function useOrganizations() {
const [organizations, setOrganizations] = useState<Organization[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let isMounted = true
const controller = new AbortController()
get(`${API_URL}/organizations`, { signal: controller.signal })
.then((res) => {
if (!res.ok) {
throw res
}
return res
})
.then((res) => res.json())
.then((data) => {
if (isMounted) {
setOrganizations(data)
setIsLoading(false)
}
})
.catch(() => {
// eat all errors
setIsLoading(false)
})
return () => {
isMounted = false
controller.abort()
}
}, [])
return {
organizations,
isLoading,
} as const
}

View File

@@ -1,18 +1,46 @@
import { getAccessToken } from 'common'
interface DataProps {
[prop: string]: any
}
export const post = (url: string, data: DataProps, options = {}) => {
export async function get(url: string, options?: RequestInit) {
const accessToken = await getAccessToken()
let headers = new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
})
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(url, {
method: 'GET',
headers,
referrerPolicy: 'no-referrer-when-downgrade',
...options,
})
}
export async function post(url: string, data: DataProps, options?: RequestInit) {
const accessToken = await getAccessToken()
let headers = new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
})
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers,
referrerPolicy: 'no-referrer-when-downgrade',
body: JSON.stringify(data),
...options,
}).catch((error) => {
console.error('Error at fetchWrapper - post:', error)
})
}

View File

@@ -43,6 +43,7 @@
"globby": "^13.2.2",
"gray-matter": "^4.0.3",
"icons": "*",
"lucide-react": "^0.436.0",
"markdown-toc": "^1.2.0",
"next": "^14.2.3",
"next-contentlayer2": "0.4.6",

View File

@@ -8,6 +8,7 @@ import ReactTooltip from 'react-tooltip'
import DefaultLayout from '~/components/Layouts/Default'
import PricingPlans from '~/components/Pricing/PricingPlans'
import { useOrganizations } from '~/data/organizations'
const PricingComputeSection = dynamic(() => import('~/components/Pricing/PricingComputeSection'))
const PricingAddons = dynamic(() => import('~/components/Pricing/PricingAddons'))
@@ -51,6 +52,9 @@ export default function IndexPage() {
}
}, [asPath])
const { isLoading, organizations } = useOrganizations()
const hasExistingOrganizations = !isLoading && organizations.length > 0
return (
<DefaultLayout>
<NextSeo
@@ -82,7 +86,10 @@ export default function IndexPage() {
</div>
</div>
<PricingPlans />
<PricingPlans
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
<div className="text-center mt-10 xl:mt-16 mx-auto max-w-lg flex flex-col gap-8">
<div className="flex justify-center gap-2">
@@ -115,7 +122,10 @@ export default function IndexPage() {
<PricingAddons />
</div>
<PricingComparisonTable />
<PricingComparisonTable
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
<div id="faq" className="border-t">
<PricingFAQs />

35
package-lock.json generated
View File

@@ -94,6 +94,14 @@
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"apps/database-new/node_modules/lucide-react": {
"version": "0.338.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.338.0.tgz",
"integrity": "sha512-Uq+vcn/gp6l01GpDH8SxY6eAvO6Ur2bSU39NxEEJt35OotnVCH5q26TZEVPtJf23gTAncXd3DJQqcezIm6HA7w==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"apps/database-new/node_modules/sql-formatter": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-13.1.0.tgz",
@@ -510,6 +518,14 @@
"url": "https://opencollective.com/unified"
}
},
"apps/design-system/node_modules/lucide-react": {
"version": "0.338.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.338.0.tgz",
"integrity": "sha512-Uq+vcn/gp6l01GpDH8SxY6eAvO6Ur2bSU39NxEEJt35OotnVCH5q26TZEVPtJf23gTAncXd3DJQqcezIm6HA7w==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"apps/design-system/node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
@@ -1651,7 +1667,7 @@
"ip-num": "^1.5.1",
"json-logic-js": "^2.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.338.0",
"lucide-react": "^0.436.0",
"markdown-table": "^3.0.3",
"mime-db": "^1.53.0",
"mobx": "^6.10.2",
@@ -2795,6 +2811,7 @@
"globby": "^13.2.2",
"gray-matter": "^4.0.3",
"icons": "*",
"lucide-react": "^0.436.0",
"markdown-toc": "^1.2.0",
"next": "^14.2.3",
"next-contentlayer2": "0.4.6",
@@ -27204,11 +27221,11 @@
}
},
"node_modules/lucide-react": {
"version": "0.338.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.338.0.tgz",
"integrity": "sha512-Uq+vcn/gp6l01GpDH8SxY6eAvO6Ur2bSU39NxEEJt35OotnVCH5q26TZEVPtJf23gTAncXd3DJQqcezIm6HA7w==",
"version": "0.436.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.436.0.tgz",
"integrity": "sha512-N292bIxoqm1aObAg0MzFtvhYwgQE6qnIOWx/GLj5ONgcTPH6N0fD9bVq/GfdeC9ZORBXozt/XeEKDpiB3x3vlQ==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/lunr": {
@@ -44524,6 +44541,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/ui/node_modules/lucide-react": {
"version": "0.338.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.338.0.tgz",
"integrity": "sha512-Uq+vcn/gp6l01GpDH8SxY6eAvO6Ur2bSU39NxEEJt35OotnVCH5q26TZEVPtJf23gTAncXd3DJQqcezIm6HA7w==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"packages/ui/node_modules/minimatch": {
"version": "5.1.6",
"dev": true,

View File

@@ -112,3 +112,35 @@ export const useIsLoggedIn = () => {
return user !== null
}
let currentSession: Session | null = null
gotrueClient.onAuthStateChange((event, session) => {
currentSession = session
})
/**
* Grabs the currently available access token, or calls getSession.
*/
export async function getAccessToken() {
// ignore if server-side
if (typeof window === 'undefined') return undefined
const aboutToExpire = currentSession?.expires_at
? currentSession.expires_at - Math.ceil(Date.now() / 1000) < 30
: false
if (!currentSession || aboutToExpire) {
const {
data: { session },
error,
} = await gotrueClient.getSession()
if (error) {
throw error
}
return session?.access_token
}
return currentSession.access_token
}