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
+}