From 9cdd412babfa8eab995d74b348b1ae1cf313d94c Mon Sep 17 00:00:00 2001 From: Julian Domke <68325451+juleswritescode@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:16:46 +0200 Subject: [PATCH] feat(stripe-atlas): mock-up dashboard to enable live testing (#50327) --- apps/studio/TANSTACK_MIGRATION.md | 1 + .../StripeAtlasApplication.tsx | 116 ++++++++++++++++++ .../StripeAtlasApplicationForm.tsx | 106 ++++++++++++++++ .../studio/pages/stripe-atlas-application.tsx | 25 ++++ apps/studio/routeTree.gen.ts | 21 ++++ .../routes/stripe-atlas-application.tsx | 11 ++ 6 files changed, 280 insertions(+) create mode 100644 apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplication.tsx create mode 100644 apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplicationForm.tsx create mode 100644 apps/studio/pages/stripe-atlas-application.tsx create mode 100644 apps/studio/routes/stripe-atlas-application.tsx diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 36861a50399..3eec2105199 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -144,6 +144,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/aws-marketplace-onboarding.tsx` ← `pages/aws-marketplace-onboarding.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `LinkAwsMarketplaceLayout` and doesn't want `AppLayout` + `DefaultLayout` wrapping. - [x] A `routes/claim-project.tsx` ← `pages/claim-project.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `` + `
` layout and doesn't want `AppLayout` + `DefaultLayout` wrapping. - [x] A `routes/join.tsx` ← `pages/join.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses a centered-div layout and doesn't want `AppLayout` + `DefaultLayout` wrapping. +- [x] A `routes/stripe-atlas-application.tsx` ← `pages/stripe-atlas-application.tsx` (no `withAuth` — reachable logged in and logged out; uses `InterstitialLayout`, so no `AppLayout` + `DefaultLayout` wrapping) - [x] `routes/_app/support/new.tsx` ← `pages/support/new.tsx` (sets `hideMobileMenu: true` staticData; existing page is `withAuth`-wrapped so no beforeLoad migration needed yet) - [x] `routes/_app/support/link.tsx` ← `pages/support/link.tsx` diff --git a/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplication.tsx b/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplication.tsx new file mode 100644 index 00000000000..96d4056d613 --- /dev/null +++ b/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplication.tsx @@ -0,0 +1,116 @@ +import { useParams } from 'common' +import { useEffect, useState } from 'react' +import { CardContent } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { z } from 'zod' + +import { StripeAtlasApplicationForm } from './StripeAtlasApplicationForm' +import { + InterstitialLayout, + LogoPair, + PartnerLogo, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' +import { BASE_PATH } from '@/lib/constants' + +function decodeBase64Param(base64encoded: string): Base64Data | null { + try { + const bytes = Uint8Array.fromBase64(base64encoded) + const decoded = new TextDecoder().decode(bytes) + return base64DataSchema.parse(JSON.parse(decoded)) + } catch { + return null + } +} + +const base64DataSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('success'), + stripeAtlasToken: z.string(), + }), + z.object({ + type: z.literal('error'), + message: z.string(), + }), +]) +type Base64Data = z.infer + +/** + * Mockup only — the form is deliberately unwired: no prefill query, no submit mutation and no + * actions, so the page can be shared for live design feedback before the perk goes live. + */ +export const StripeAtlasApplicationScreen = () => { + const params = useParams() + + // give the router a chance to parse the query params + const [hydrated, setHydrated] = useState(false) + useEffect(() => setHydrated(true), []) + if (!hydrated) { + return ( + + + + ) + } + + const decoded = params.data ? decodeBase64Param(params.data) : null + + return ( + } + right={} + /> + } + title="$500 Credit Code for Stripe Atlas Merchants" + description="Confirm your details below. Once the application is confirmed, you will receive a credit code via email." + > + {decoded === null && ( + + + We currently only support pre-filled applications for Stripe Atlas Perks. +
+ Please head to the Stripe Atlas dashboard and use the "Pre-fill my application" + button. +

+ } + /> +
+ )} + {decoded?.type === 'error' && ( + + {decoded.message}

} + /> +
+ )} + {decoded?.type === 'success' && ( + + + + )} +
+ ) +} + +const LoadingCard = () => { + return ( + +
+ + +
+ + + +
+ ) +} diff --git a/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplicationForm.tsx b/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplicationForm.tsx new file mode 100644 index 00000000000..e600c4c85f2 --- /dev/null +++ b/apps/studio/components/interfaces/StripeAtlasApplication/StripeAtlasApplicationForm.tsx @@ -0,0 +1,106 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { Button, Form, FormControl, FormField, Input } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' + +const FormSchema = z.object({ + firstname: z.string().trim().min(1, 'First name is required').max(100, 'Maximum 100 characters'), + lastname: z.string().trim().min(1, 'Last name is required').max(100, 'Maximum 100 characters'), + companyName: z + .string() + .trim() + .min(1, 'Company name is required') + .max(200, 'Maximum 200 characters'), + email: z + .string() + .trim() + .min(1, 'Email is required') + .max(254, 'Maximum 254 characters') + .email('Enter a valid email address'), +}) + +type FormValues = z.infer + +type StripeAtlasApplicationFormProps = { + stripeAtlasToken: string +} + +/** Mockup only — submitting runs validation and stops there, nothing is sent. */ +export const StripeAtlasApplicationForm = (_props: StripeAtlasApplicationFormProps) => { + // todo(@juleswritescode): fetch data from API via props.stripeAtlasToken; currently just a mockup. + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + firstname: 'Mockey', + lastname: 'Mockupson', + companyName: 'Acmo Ck.', + email: 'me@mo.ck', + }, + }) + + return ( +
+ {})}> +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> +
+ + ( + + + + + + )} + /> + + ( + + + + + + )} + /> + + + + + ) +} diff --git a/apps/studio/pages/stripe-atlas-application.tsx b/apps/studio/pages/stripe-atlas-application.tsx new file mode 100644 index 00000000000..7c820b94e1b --- /dev/null +++ b/apps/studio/pages/stripe-atlas-application.tsx @@ -0,0 +1,25 @@ +import Head from 'next/head' + +import { StripeAtlasApplicationScreen } from '@/components/interfaces/StripeAtlasApplication/StripeAtlasApplication' +import { buildStudioPageTitle } from '@/lib/page-title' +import type { NextPageWithLayout } from '@/types' + +const PAGE_TITLE = buildStudioPageTitle({ section: 'Stripe Atlas Application', brand: 'Supabase' }) + +/** + * Page needs to be pre-auth – customers will be redirected here from the + * Stripe Atlas dashboard, either directly via a static link or via a callbackURL from the mgmt API. + * The user's auth state doesn't matter for the application; what matters is the the query params of the callbackURL. + */ +const StripeAtlasApplicationPage: NextPageWithLayout = () => { + return ( + <> + + {PAGE_TITLE} + + + + ) +} + +export default StripeAtlasApplicationPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 76b8d02e7ec..7b24e9d7b2c 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VerifyEmailRouteImport } from './routes/verify-email' +import { Route as StripeAtlasApplicationRouteImport } from './routes/stripe-atlas-application' import { Route as RedeemRouteImport } from './routes/redeem' import { Route as MaintenanceRouteImport } from './routes/maintenance' import { Route as LogoutRouteImport } from './routes/logout' @@ -335,6 +336,11 @@ const VerifyEmailRoute = VerifyEmailRouteImport.update({ path: '/verify-email', getParentRoute: () => rootRouteImport, } as any) +const StripeAtlasApplicationRoute = StripeAtlasApplicationRouteImport.update({ + id: '/stripe-atlas-application', + path: '/stripe-atlas-application', + getParentRoute: () => rootRouteImport, +} as any) const RedeemRoute = RedeemRouteImport.update({ id: '/redeem', path: '/redeem', @@ -2129,6 +2135,7 @@ export interface FileRoutesByFullPath { '/logout': typeof LogoutRoute '/maintenance': typeof MaintenanceRoute '/redeem': typeof RedeemRoute + '/stripe-atlas-application': typeof StripeAtlasApplicationRoute '/verify-email': typeof VerifyEmailRoute '/account': typeof AppAccountRouteWithChildren '/org': typeof AppOrgRouteWithChildren @@ -2449,6 +2456,7 @@ export interface FileRoutesByTo { '/logout': typeof LogoutRoute '/maintenance': typeof MaintenanceRoute '/redeem': typeof RedeemRoute + '/stripe-atlas-application': typeof StripeAtlasApplicationRoute '/verify-email': typeof VerifyEmailRoute '/account': typeof AppAccountRouteWithChildren '/organizations': typeof AppOrganizationsRoute @@ -2758,6 +2766,7 @@ export interface FileRoutesById { '/logout': typeof LogoutRoute '/maintenance': typeof MaintenanceRoute '/redeem': typeof RedeemRoute + '/stripe-atlas-application': typeof StripeAtlasApplicationRoute '/verify-email': typeof VerifyEmailRoute '/_app/account': typeof AppAccountRouteWithChildren '/_app/org': typeof AppOrgRouteWithChildren @@ -3080,6 +3089,7 @@ export interface FileRouteTypes { | '/logout' | '/maintenance' | '/redeem' + | '/stripe-atlas-application' | '/verify-email' | '/account' | '/org' @@ -3400,6 +3410,7 @@ export interface FileRouteTypes { | '/logout' | '/maintenance' | '/redeem' + | '/stripe-atlas-application' | '/verify-email' | '/account' | '/organizations' @@ -3708,6 +3719,7 @@ export interface FileRouteTypes { | '/logout' | '/maintenance' | '/redeem' + | '/stripe-atlas-application' | '/verify-email' | '/_app/account' | '/_app/org' @@ -4031,6 +4043,7 @@ export interface RootRouteChildren { LogoutRoute: typeof LogoutRoute MaintenanceRoute: typeof MaintenanceRoute RedeemRoute: typeof RedeemRoute + StripeAtlasApplicationRoute: typeof StripeAtlasApplicationRoute VerifyEmailRoute: typeof VerifyEmailRoute ApiCheckCnameRoute: typeof ApiCheckCnameRoute ApiCliReleaseVersionRoute: typeof ApiCliReleaseVersionRoute @@ -4153,6 +4166,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof VerifyEmailRouteImport parentRoute: typeof rootRouteImport } + '/stripe-atlas-application': { + id: '/stripe-atlas-application' + path: '/stripe-atlas-application' + fullPath: '/stripe-atlas-application' + preLoaderRoute: typeof StripeAtlasApplicationRouteImport + parentRoute: typeof rootRouteImport + } '/redeem': { id: '/redeem' path: '/redeem' @@ -7120,6 +7140,7 @@ const rootRouteChildren: RootRouteChildren = { LogoutRoute: LogoutRoute, MaintenanceRoute: MaintenanceRoute, RedeemRoute: RedeemRoute, + StripeAtlasApplicationRoute: StripeAtlasApplicationRoute, VerifyEmailRoute: VerifyEmailRoute, ApiCheckCnameRoute: ApiCheckCnameRoute, ApiCliReleaseVersionRoute: ApiCliReleaseVersionRoute, diff --git a/apps/studio/routes/stripe-atlas-application.tsx b/apps/studio/routes/stripe-atlas-application.tsx new file mode 100644 index 00000000000..9cde11b9b55 --- /dev/null +++ b/apps/studio/routes/stripe-atlas-application.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +import StripeAtlasApplicationPage from '@/pages/stripe-atlas-application' + +export const Route = createFileRoute('/stripe-atlas-application')({ + component: StripeAtlasApplicationRoute, +}) + +function StripeAtlasApplicationRoute() { + return +}