diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts index dc77fa3d5d..c09db08b89 100644 --- a/apps/studio/lib/gotrue.ts +++ b/apps/studio/lib/gotrue.ts @@ -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 => { try { diff --git a/apps/studio/package.json b/apps/studio/package.json index 51a9db6e86..f496c4d0e0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/www/components/Pricing/PricingComparisonTable.tsx b/apps/www/components/Pricing/PricingComparisonTable.tsx index ac928b2f55..ae7c4132f4 100644 --- a/apps/www/components/Pricing/PricingComparisonTable.tsx +++ b/apps/www/components/Pricing/PricingComparisonTable.tsx @@ -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 ( -
-

{plan}

-
- {from && From} - {showDollarSign ? ( - - {plan !== 'Enterprise' ? '$' : ''} - {price} - - ) : ( - {price} - )} + return ( +
+

{plan}

+
+ {from && From} + {showDollarSign ? ( + + {plan !== 'Enterprise' ? '$' : ''} + {price} + + ) : ( + {price} + )} -

{priceDescription}

-
-

{description}

+

{priceDescription}

+
+

{description}

+ {isUpgradablePlan && hasExistingOrganizations ? ( + + sendTelemetryEvent( + gaEvents[`www_pricing_comparison_${plan.toLowerCase()}_mobile_upgrade`] + ) + } + size="medium" + /> + ) : ( -
- ) + )} +
+ ) +} + +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} /> { price={'599'} priceDescription={'/month + additional use'} description={'Collaborate with different permissions and access patterns'} + organizations={organizations} + hasExistingOrganizations={hasExistingOrganizations} /> { /> - {plans.map((plan) => ( - - - -

- {plan.name} -

-

- - {plan.name !== 'Enterprise' && '$'} - {plan.priceMonthly} - - {['Free', 'Pro', 'Team'].includes(plan.name) && ( - {plan.costUnit} - )} -

-
- - + + {plan.name !== 'Enterprise' && '$'} + {plan.priceMonthly} + + {['Free', 'Pro', 'Team'].includes(plan.name) && ( + {plan.costUnit} + )} +

+
+ + {isUpgradablePlan && hasExistingOrganizations ? ( + + sendTelemetryEvent( + gaEvents[ + `www_pricing_comparison_${plan.name.toLowerCase()}_upgrade` + ] + ) + } + size="tiny" + /> + ) : ( + + )} +
- - - ))} + + ) + })} diff --git a/apps/www/components/Pricing/PricingPlans.tsx b/apps/www/components/Pricing/PricingPlans.tsx index 716a0446f9..1942b08960 100644 --- a/apps/www/components/Pricing/PricingPlans.tsx +++ b/apps/www/components/Pricing/PricingPlans.tsx @@ -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 = () => {
{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 (
@@ -56,28 +67,25 @@ const PricingPlans: FC = () => {

{plan.description}

- + + {plan.cta} + + + )}
{
{plan.preface && ( diff --git a/apps/www/components/Pricing/UpgradePlan.tsx b/apps/www/components/Pricing/UpgradePlan.tsx new file mode 100644 index 0000000000..57f8d2efa0 --- /dev/null +++ b/apps/www/components/Pricing/UpgradePlan.tsx @@ -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 ( + + + + + + + Upgrade organization + + Choose the organization you want to upgrade to a paid plan. + + + + + + + + + + + + + No organizations found. + + {organizations.map((organization) => ( + { + setValue(currentValue === value ? '' : currentValue) + setOpen(false) + }} + keywords={[organization.name]} + > + + {organization.name} + + ))} + + + + { + setValue(currentValue === value ? '' : currentValue) + setOpen(false) + }} + keywords={['Create a new organization']} + > + + Create a new organization + + + + + + + + + + + Upon continuing, you will be redirected to the organization's billing page where + you can upgrade to a paid plan. + + + + + + + + + + + + ) +} + +export default UpgradePlan diff --git a/apps/www/data/organizations.ts b/apps/www/data/organizations.ts new file mode 100644 index 0000000000..26b7dc2119 --- /dev/null +++ b/apps/www/data/organizations.ts @@ -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([]) + 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 +} diff --git a/apps/www/lib/fetchWrapper.ts b/apps/www/lib/fetchWrapper.ts index 0313e49565..e5ba7a2907 100644 --- a/apps/www/lib/fetchWrapper.ts +++ b/apps/www/lib/fetchWrapper.ts @@ -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) }) } diff --git a/apps/www/package.json b/apps/www/package.json index 575e8f9fdf..b5d799257d 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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", diff --git a/apps/www/pages/pricing.tsx b/apps/www/pages/pricing.tsx index 34dd396463..5fea81a4d0 100644 --- a/apps/www/pages/pricing.tsx +++ b/apps/www/pages/pricing.tsx @@ -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 (
- +
@@ -115,7 +122,10 @@ export default function IndexPage() {
- +
diff --git a/package-lock.json b/package-lock.json index 2f9ae03592..be77673d1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/packages/common/auth.tsx b/packages/common/auth.tsx index 2a8bc0d8c2..d77acc7657 100644 --- a/packages/common/auth.tsx +++ b/packages/common/auth.tsx @@ -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 +}