@@ -567,10 +567,16 @@ function FairyInvites() {
{invites.map((invite) => (
{invite.id}
+ {invite.description || '-'}
{invite.fairyLimit}
{invite.currentUses} / {invite.maxUses === 0 ? '∞' : invite.maxUses}
+
+ {invite.redeemedBy && invite.redeemedBy.length > 0
+ ? invite.redeemedBy.join(', ')
+ : '-'}
+
{new Date(invite.createdAt).toLocaleString()}
>({})
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null as string | null)
+ const [successMessage, setSuccessMessage] = useState(null as string | null)
+
+ const loadFlags = useCallback(async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/app/admin/feature-flags')
+ if (!res.ok) {
+ setError(res.statusText + ': ' + (await res.text()))
+ return
+ }
+ const data = await res.json()
+ setFlags(data)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load flags')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ loadFlags()
+ }, [loadFlags])
+
+ const toggleFlag = useCallback(async (flag: string, enabled: boolean) => {
+ setIsSaving(true)
+ setError(null)
+ setSuccessMessage(null)
+ try {
+ const res = await fetch('/api/app/admin/feature-flags', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ flag, enabled }),
+ })
+ if (!res.ok) {
+ setError(res.statusText + ': ' + (await res.text()))
+ return
+ }
+ setFlags((prev) => ({ ...prev, [flag]: { ...prev[flag], enabled } }))
+ setSuccessMessage(`${flag} ${enabled ? 'enabled' : 'disabled'}`)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update flag')
+ } finally {
+ setIsSaving(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (successMessage) {
+ const timer = setTimeout(() => setSuccessMessage(null), 3000)
+ return () => clearTimeout(timer)
+ }
+ }, [successMessage])
+
+ return (
+
+ {error &&
{error}
}
+ {successMessage &&
{successMessage}
}
+
+
+ Global feature toggles. Changes take effect immediately for ALL users.
+
+
+ Unchecking these flags will completely disable the feature for everyone, regardless of their
+ individual access settings.
+
+
+ {isLoading ? (
+
Loading flags...
+ ) : (
+
+ {Object.entries(flags)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([flagName, flagValue]) => {
+ const label = flagName
+ .split('_')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ')
+ return (
+
+
+ toggleFlag(flagName, e.target.checked)}
+ disabled={isSaving}
+ />
+
+ {label}
+
+
+ {flagValue.description && (
+
+ {flagValue.description}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
function HardDeleteFile() {
const inputRef = useRef(null)
const [error, setError] = useState(null as string | null)
diff --git a/apps/dotcom/client/src/pages/pricing.module.css b/apps/dotcom/client/src/pages/pricing.module.css
index 70879cdaece2..0c145158ae07 100644
--- a/apps/dotcom/client/src/pages/pricing.module.css
+++ b/apps/dotcom/client/src/pages/pricing.module.css
@@ -24,6 +24,28 @@
.content {
max-width: 540px;
width: 100%;
+ position: relative;
+}
+
+.homeButton {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 12px;
+ color: var(--tl-color-text-3);
+ text-decoration: none;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+@media (hover: hover) {
+ .homeButton:hover {
+ color: var(--tl-color-text-1);
+ background-color: var(--tl-color-hover);
+ }
}
.logo {
diff --git a/apps/dotcom/client/src/pages/pricing.tsx b/apps/dotcom/client/src/pages/pricing.tsx
index 0e3a326ee234..56fe020157cb 100644
--- a/apps/dotcom/client/src/pages/pricing.tsx
+++ b/apps/dotcom/client/src/pages/pricing.tsx
@@ -1,4 +1,11 @@
-import { lazy, Suspense } from 'react'
+import { lazy, Suspense, useCallback, useEffect, useState } from 'react'
+import { Link, useNavigate, useSearchParams } from 'react-router-dom'
+import { setInSessionStorage, useDialogs } from 'tldraw'
+import { TlaSignInDialog } from '../tla/components/dialogs/TlaSignInDialog'
+import { useFairyAccess } from '../tla/hooks/useFairyAccess'
+import { useFeatureFlags } from '../tla/hooks/useFeatureFlags'
+import { usePaddle } from '../tla/hooks/usePaddle'
+import { useTldrawUser } from '../tla/hooks/useUser'
import '../tla/styles/fairy.css'
import { F } from '../tla/utils/i18n'
import styles from './pricing.module.css'
@@ -8,9 +15,106 @@ const FairySprite = lazy(() =>
)
export function Component() {
+ const user = useTldrawUser()
+ const hasFairyAccess = useFairyAccess()
+ const { addDialog } = useDialogs()
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const [isProcessing, setIsProcessing] = useState(false)
+ const { paddleLoaded, openPaddleCheckout } = usePaddle()
+ const { flags, isLoaded } = useFeatureFlags()
+
+ // Handle checkout intent from search params (after sign-in redirect)
+ useEffect(() => {
+ if (!isLoaded) return // Wait for flags to load
+ if (searchParams.get('checkout') === 'true' && user && paddleLoaded) {
+ // Clear the param
+ setSearchParams((params) => {
+ params.delete('checkout')
+ return params
+ })
+
+ // Don't open checkout if disabled or user already has access
+ if (!flags.fairies.enabled || !flags.fairies_purchase.enabled || hasFairyAccess) {
+ return
+ }
+
+ // Open checkout
+ setTimeout(() => {
+ openPaddleCheckout(user.id, user.clerkUser.primaryEmailAddress?.emailAddress)
+ }, 100)
+ }
+ }, [
+ searchParams,
+ user,
+ paddleLoaded,
+ hasFairyAccess,
+ openPaddleCheckout,
+ setSearchParams,
+ navigate,
+ flags.fairies.enabled,
+ flags.fairies_purchase.enabled,
+ isLoaded,
+ ])
+
+ const handlePurchaseClick = useCallback(() => {
+ if (isProcessing) return
+ if (!isLoaded) return // Wait for flags to load
+
+ // Don't allow purchase if fairies feature is disabled
+ if (!flags.fairies.enabled) {
+ return
+ }
+
+ // If user already has fairy access, go home (check before purchase flag)
+ if (user && hasFairyAccess) {
+ navigate('/')
+ return
+ }
+
+ // Don't allow purchase if purchase flag is disabled
+ if (!flags.fairies_purchase.enabled) {
+ return
+ }
+
+ if (!user) {
+ // Store redirect path for after sign-in
+ setInSessionStorage('redirect-to', '/pricing?checkout=true')
+ addDialog({ component: TlaSignInDialog })
+ return
+ }
+
+ // User is signed in, open Paddle directly
+ if (!paddleLoaded) {
+ return
+ }
+
+ setIsProcessing(true)
+ const success = openPaddleCheckout(user.id, user.clerkUser.primaryEmailAddress?.emailAddress)
+ if (!success) {
+ setIsProcessing(false)
+ }
+ // Keep processing state until checkout completes or user closes overlay
+ setTimeout(() => setIsProcessing(false), 1000)
+ }, [
+ user,
+ hasFairyAccess,
+ paddleLoaded,
+ isProcessing,
+ addDialog,
+ navigate,
+ openPaddleCheckout,
+ flags.fairies.enabled,
+ flags.fairies_purchase.enabled,
+ isLoaded,
+ ])
+
return (
+
+
+
@@ -51,8 +155,18 @@ export function Component() {
-
-
+
+ {!user ? (
+
+ ) : hasFairyAccess ? (
+
+ ) : (
+
+ )}
diff --git a/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarFairyCheckoutLink.tsx b/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarFairyCheckoutLink.tsx
index 0e502e6d1e14..182831e57b73 100644
--- a/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarFairyCheckoutLink.tsx
+++ b/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarFairyCheckoutLink.tsx
@@ -1,134 +1,30 @@
-import { useCallback, useEffect, useState } from 'react'
import { useValue } from 'tldraw'
import { useApp } from '../../../hooks/useAppState'
import { useFairyLimit } from '../../../hooks/useFairyAccess'
+import { useFeatureFlags } from '../../../hooks/useFeatureFlags'
+import { usePaddle } from '../../../hooks/usePaddle'
import { F } from '../../../utils/i18n'
import styles from '../sidebar.module.css'
-declare global {
- interface Window {
- Paddle?: {
- Environment: {
- set(env: 'sandbox' | 'production'): void
- }
- Initialize(config: { token: string; eventCallback?(data: any): void }): void
- Checkout: {
- open(options: {
- items: Array<{ priceId: string; quantity: number }>
- customData?: Record
- customer?: {
- email?: string
- }
- settings?: {
- allowDiscountRemoval?: boolean
- displayMode?: string
- showAddDiscounts?: boolean
- }
- }): void
- }
- }
- }
-}
-
export function TlaSidebarFairyCheckoutLink() {
const app = useApp()
- const [paddleLoaded, setPaddleLoaded] = useState(false)
-
- // Load Paddle script
- const loadPaddleScript = useCallback(() => {
- if (paddleLoaded) return
-
- // Check if script already exists
- if (window.Paddle) {
- setPaddleLoaded(true)
- return
- }
-
- const paddleEnv = (process.env.PADDLE_ENVIRONMENT as 'sandbox' | 'production') ?? 'sandbox'
- const paddleToken = process.env.PADDLE_CLIENT_TOKEN
-
- if (!paddleToken) {
- console.error('[Paddle] Client token not configured')
- return
- }
-
- const script = document.createElement('script')
- script.src = 'https://cdn.paddle.com/paddle/v2/paddle.js'
- script.async = true
- script.onload = () => {
- // Initialize Paddle once when script loads
- if (window.Paddle) {
- window.Paddle.Environment.set(paddleEnv)
- window.Paddle.Initialize({
- token: paddleToken,
- eventCallback: (data) => {
- if (data.name === 'checkout.completed') {
- console.warn('[Paddle] Checkout completed', data.data?.transaction_id)
- }
- },
- })
- setPaddleLoaded(true)
- }
- }
- script.onerror = () => {
- console.error('Failed to load Paddle script')
- }
-
- document.head.appendChild(script)
- }, [paddleLoaded])
+ const { paddleLoaded, openPaddleCheckout } = usePaddle()
+ const { flags } = useFeatureFlags()
// Show button only if user has no fairy access
const currentFairyLimit = useFairyLimit()
const user = useValue('user', () => app.getUser(), [app])
const userEmail = user?.email
- // Load Paddle on mount
- useEffect(() => {
- loadPaddleScript()
- }, [loadPaddleScript])
-
// Early returns after all hooks
- if (currentFairyLimit === null || currentFairyLimit > 0) return null // Hide button if user already has access
+ if (!flags.fairies.enabled || !flags.fairies_purchase.enabled) return null
+ if (currentFairyLimit && currentFairyLimit > 0) return null
const handlePurchase = () => {
const userId = app.userId
- if (!userId) {
- console.error('No user ID found')
- return
- }
-
- // Wait for Paddle to load
- if (!paddleLoaded || !window.Paddle) {
- console.error('Paddle.js not loaded yet')
- return
- }
-
- const paddlePriceId = process.env.PADDLE_FAIRY_PRICE_ID
-
- if (!paddlePriceId) {
- console.error('[Paddle] Price ID not configured')
- return
- }
+ if (!userId || !paddleLoaded) return
- try {
- // Open checkout with quantity 1 (which grants 3 fairies)
- window.Paddle.Checkout.open({
- items: [{ priceId: paddlePriceId, quantity: 1 }],
- customData: {
- userId,
- },
- customer: {
- email: userEmail,
- },
- settings: {
- allowDiscountRemoval: false,
- displayMode: 'overlay',
- showAddDiscounts: false,
- },
- })
- } catch (error) {
- console.error('Failed to open Paddle checkout:', error)
- }
+ openPaddleCheckout(userId, userEmail)
}
return (
diff --git a/apps/dotcom/client/src/tla/hooks/useFairyAccess.ts b/apps/dotcom/client/src/tla/hooks/useFairyAccess.ts
index fb0ab408ab6d..f09b1e557f49 100644
--- a/apps/dotcom/client/src/tla/hooks/useFairyAccess.ts
+++ b/apps/dotcom/client/src/tla/hooks/useFairyAccess.ts
@@ -2,28 +2,31 @@ import { useUser } from '@clerk/clerk-react'
import { hasActiveFairyAccess } from '@tldraw/dotcom-shared'
import { useValue } from 'tldraw'
import { useMaybeApp } from './useAppState'
+import { useFeatureFlags } from './useFeatureFlags'
/**
* Hook that returns whether the current user has active fairy access.
+ * Checks both the feature flag and user's fairy access settings.
*/
export function useFairyAccess(): boolean {
const app = useMaybeApp()
const { user: clerkUser } = useUser()
+ const { flags } = useFeatureFlags()
return useValue(
'fairy_access',
() => {
+ if (!flags.fairies.enabled) return false
if (!app) return false
const user = app.getUser()
if (!clerkUser || !user) return false
return hasActiveFairyAccess(user.fairyAccessExpiresAt, user.fairyLimit)
},
- [app, clerkUser]
+ [app, clerkUser, flags.fairies.enabled]
)
}
/**
* Hook that returns the user's fairy limit.
- * A value of 0 means that they can purchases faries, but don't have access yet.
* Returns null if the user has no fairy access at all.
*/
export function useFairyLimit(): number | null {
diff --git a/apps/dotcom/client/src/tla/hooks/useFeatureFlags.ts b/apps/dotcom/client/src/tla/hooks/useFeatureFlags.ts
new file mode 100644
index 000000000000..25ce22e1d131
--- /dev/null
+++ b/apps/dotcom/client/src/tla/hooks/useFeatureFlags.ts
@@ -0,0 +1,12 @@
+import { useValue } from 'tldraw'
+import { featureFlagsAtom, featureFlagsLoadedAtom } from '../utils/FeatureFlagsFetcher'
+
+/**
+ * Hook that returns the current feature flags from the global atom.
+ * Flags are fetched and polled by FeatureFlagsFetcher.
+ */
+export function useFeatureFlags() {
+ const flags = useValue('feature-flags', () => featureFlagsAtom.get(), [])
+ const isLoaded = useValue('feature-flags-loaded', () => featureFlagsLoadedAtom.get(), [])
+ return { flags, isLoaded }
+}
diff --git a/apps/dotcom/client/src/tla/hooks/usePaddle.ts b/apps/dotcom/client/src/tla/hooks/usePaddle.ts
new file mode 100644
index 000000000000..20ddc8e2a7ee
--- /dev/null
+++ b/apps/dotcom/client/src/tla/hooks/usePaddle.ts
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+declare global {
+ interface Window {
+ Paddle?: {
+ Environment: {
+ set(env: 'sandbox' | 'production'): void
+ }
+ Initialize(config: { token: string; eventCallback?(data: any): void }): void
+ Checkout: {
+ open(options: {
+ items: Array<{ priceId: string; quantity: number }>
+ customData?: Record
+ customer?: {
+ email?: string
+ }
+ settings?: {
+ allowDiscountRemoval?: boolean
+ displayMode?: string
+ showAddDiscounts?: boolean
+ }
+ }): void
+ }
+ }
+ }
+}
+
+export function usePaddle() {
+ const [paddleLoaded, setPaddleLoaded] = useState(false)
+ const scriptLoadingRef = useRef(false)
+
+ const loadPaddleScript = useCallback(() => {
+ if (paddleLoaded || window.Paddle || scriptLoadingRef.current) {
+ if (window.Paddle) setPaddleLoaded(true)
+ return
+ }
+
+ // Check if script already exists in DOM
+ if (document.querySelector('script[src*="paddle.com/paddle"]')) {
+ if (window.Paddle) setPaddleLoaded(true)
+ return
+ }
+
+ scriptLoadingRef.current = true
+
+ const paddleEnv = (process.env.PADDLE_ENVIRONMENT as 'sandbox' | 'production') ?? 'sandbox'
+ const paddleToken = process.env.PADDLE_CLIENT_TOKEN
+
+ if (!paddleToken) {
+ scriptLoadingRef.current = false
+ return
+ }
+
+ const script = document.createElement('script')
+ script.src = 'https://cdn.paddle.com/paddle/v2/paddle.js'
+ script.async = true
+ script.onload = () => {
+ scriptLoadingRef.current = false
+ if (window.Paddle) {
+ window.Paddle.Environment.set(paddleEnv)
+ window.Paddle.Initialize({
+ token: paddleToken,
+ eventCallback: (data) => {
+ if (data.name === 'checkout.completed') {
+ window.location.href = '/'
+ }
+ },
+ })
+ setPaddleLoaded(true)
+ }
+ }
+ script.onerror = () => {
+ scriptLoadingRef.current = false
+ }
+
+ document.head.appendChild(script)
+ }, [paddleLoaded])
+
+ useEffect(() => {
+ loadPaddleScript()
+ }, [loadPaddleScript])
+
+ const openPaddleCheckout = useCallback((userId: string, email?: string) => {
+ if (!window.Paddle) return false
+
+ const paddlePriceId = process.env.PADDLE_FAIRY_PRICE_ID
+ if (!paddlePriceId) return false
+
+ try {
+ window.Paddle.Checkout.open({
+ items: [{ priceId: paddlePriceId, quantity: 1 }],
+ customData: { userId },
+ customer: { email },
+ settings: {
+ allowDiscountRemoval: false,
+ displayMode: 'overlay',
+ showAddDiscounts: false,
+ },
+ })
+ return true
+ } catch {
+ return false
+ }
+ }, [])
+
+ return { paddleLoaded, openPaddleCheckout }
+}
diff --git a/apps/dotcom/client/src/tla/pages/fairy-invite.tsx b/apps/dotcom/client/src/tla/pages/fairy-invite.tsx
index e38c4c632b01..13f07b53f07b 100644
--- a/apps/dotcom/client/src/tla/pages/fairy-invite.tsx
+++ b/apps/dotcom/client/src/tla/pages/fairy-invite.tsx
@@ -1,36 +1,24 @@
-import { useEffect, useMemo } from 'react'
+import { useAuth } from '@clerk/clerk-react'
+import { useEffect } from 'react'
import { Navigate, useParams } from 'react-router-dom'
-import { useToasts } from 'tldraw'
-import { routes } from '../../routeDefs'
-import { useFairyAccess } from '../hooks/useFairyAccess'
-import { defineMessages, useMsg } from '../utils/i18n'
-
-const messages = defineMessages({
- alreadyHasAccess: { defaultMessage: 'You already have fairy access!' },
-})
+import { setInSessionStorage, useDialogs } from 'tldraw'
+import { TlaSignInDialog } from '../components/dialogs/TlaSignInDialog'
export function Component() {
const { token } = useParams<{ token: string }>()
- const { addToast } = useToasts()
- const alreadyHasAccessMsg = useMsg(messages.alreadyHasAccess)
- const userHasActiveFairyAccess = useFairyAccess()
-
- // Memoize the state object to prevent Navigate from re-rendering infinitely
- const navigateState = useMemo(() => ({ fairyInviteToken: token }), [token])
+ const auth = useAuth()
+ const { addDialog } = useDialogs()
useEffect(() => {
- if (userHasActiveFairyAccess) {
- addToast({
- id: 'fairy-invite-already-has-access',
- title: alreadyHasAccessMsg,
- })
- }
- }, [userHasActiveFairyAccess, addToast, alreadyHasAccessMsg])
+ // Store token in session storage - handlers will process after sign-in
+ setInSessionStorage('fairy-invite-token', token!)
- // If user already has access, redirect without showing dialog
- if (userHasActiveFairyAccess) {
- return
- }
+ // If user is not signed in, show sign-in dialog
+ if (auth.isLoaded && !auth.isSignedIn) {
+ addDialog({ component: TlaSignInDialog })
+ }
+ }, [token, auth.isLoaded, auth.isSignedIn, addDialog])
- return
+ // Always redirect to root - handlers will process the token and check flags
+ return
}
diff --git a/apps/dotcom/client/src/tla/pages/file.tsx b/apps/dotcom/client/src/tla/pages/file.tsx
index 31a60ccf6f95..8ed9a57e4db7 100644
--- a/apps/dotcom/client/src/tla/pages/file.tsx
+++ b/apps/dotcom/client/src/tla/pages/file.tsx
@@ -1,10 +1,9 @@
import { captureException } from '@sentry/react'
import { useEffect } from 'react'
-import { useLocation, useParams, useRouteError } from 'react-router-dom'
+import { useParams, useRouteError } from 'react-router-dom'
import { useDialogs } from 'tldraw'
import { TlaEditor } from '../components/TlaEditor/TlaEditor'
import { TlaFileError } from '../components/TlaFileError/TlaFileError'
-import { TlaFairyInviteDialog } from '../components/dialogs/TlaFairyInviteDialog'
import { TlaInviteDialog } from '../components/dialogs/TlaInviteDialog'
import { useMaybeApp } from '../hooks/useAppState'
import { useInviteDetails } from '../hooks/useInviteDetails'
@@ -28,8 +27,6 @@ export function Component({ error }: { error?: unknown }) {
const userId = app?.userId
const inviteInfo = useInviteDetails()
const dialogs = useDialogs()
- const location = useLocation()
- const fairyInviteToken = location.state?.fairyInviteToken
const errorElem = error ? : null
@@ -48,16 +45,6 @@ export function Component({ error }: { error?: unknown }) {
}
}, [inviteInfo, dialogs, userId])
- useEffect(() => {
- if (fairyInviteToken) {
- dialogs.addDialog({
- component: ({ onClose }) => (
-
- ),
- })
- }
- }, [fairyInviteToken, dialogs])
-
useEffect(() => {
if (app && fileSlug) {
app.ensureFileVisibleInSidebar(fileSlug)
diff --git a/apps/dotcom/client/src/tla/pages/local.tsx b/apps/dotcom/client/src/tla/pages/local.tsx
index c7f2f77850c1..cffc6b1655e3 100644
--- a/apps/dotcom/client/src/tla/pages/local.tsx
+++ b/apps/dotcom/client/src/tla/pages/local.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
-import { assert, react, useDialogs } from 'tldraw'
+import { assert, deleteFromSessionStorage, getFromSessionStorage, react, useDialogs } from 'tldraw'
import { LocalEditor } from '../../components/LocalEditor'
import { routes } from '../../routeDefs'
import { globalEditor } from '../../utils/globalEditor'
@@ -21,6 +21,14 @@ export function Component() {
const handleFileOperations = async () => {
if (!app) return
+ // Check for redirect-to first (e.g., after OAuth sign-in)
+ const redirectTo = getFromSessionStorage('redirect-to')
+ if (redirectTo) {
+ deleteFromSessionStorage('redirect-to')
+ navigate(redirectTo, { replace: true })
+ return
+ }
+
if (getShouldSlurpFile()) {
const res = await app.slurpFile()
if (res.ok) {
diff --git a/apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx b/apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx
index 21091fbc6311..42aac1253196 100644
--- a/apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx
+++ b/apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx
@@ -13,7 +13,9 @@ import {
TLUiEventHandler,
TldrawUiA11yProvider,
TldrawUiContextProvider,
+ deleteFromSessionStorage,
fetch,
+ getFromSessionStorage,
runtime,
setRuntimeOverrides,
useDialogs,
@@ -27,11 +29,15 @@ import { globalEditor } from '../../utils/globalEditor'
import { MaybeForceUserRefresh } from '../components/MaybeForceUserRefresh/MaybeForceUserRefresh'
import { components } from '../components/TlaEditor/TlaEditor'
import { TlaCookieConsent } from '../components/dialogs/TlaCookieConsent'
+import { TlaFairyInviteDialog } from '../components/dialogs/TlaFairyInviteDialog'
import { TlaLegalAcceptance } from '../components/dialogs/TlaLegalAcceptance'
import { AppStateProvider, useMaybeApp } from '../hooks/useAppState'
+import { useFairyAccess } from '../hooks/useFairyAccess'
+import { useFeatureFlags } from '../hooks/useFeatureFlags'
import { UserProvider } from '../hooks/useUser'
import '../styles/tla.css'
-import { IntlProvider, defineMessages, setupCreateIntl, useIntl } from '../utils/i18n'
+import { FeatureFlagsFetcher } from '../utils/FeatureFlagsFetcher'
+import { IntlProvider, defineMessages, setupCreateIntl, useIntl, useMsg } from '../utils/i18n'
import {
clearLocalSessionState,
getLocalSessionState,
@@ -163,6 +169,7 @@ function InsideOfContainerContext({ children }: { children: ReactNode }) {
+
{currentEditor && }
@@ -177,6 +184,62 @@ function PutToastsInApp() {
return null
}
+const fairyInviteMessages = defineMessages({
+ alreadyHasAccess: { defaultMessage: 'You already have fairy access!' },
+})
+
+function FairyInviteHandler() {
+ const auth = useAuth()
+ const dialogs = useDialogs()
+ const { addToast } = useToasts()
+ const { flags, isLoaded } = useFeatureFlags()
+ const hasFairyAccess = useFairyAccess()
+ const alreadyHasAccessMsg = useMsg(fairyInviteMessages.alreadyHasAccess)
+
+ useEffect(() => {
+ if (!auth.isLoaded) return
+ if (!auth.isSignedIn || !auth.userId) return
+ if (!isLoaded) return // Wait for flags to load before processing
+
+ const storedToken = getFromSessionStorage('fairy-invite-token')
+
+ if (storedToken) {
+ deleteFromSessionStorage('fairy-invite-token')
+
+ // Show toast if user already has access
+ if (hasFairyAccess) {
+ addToast({
+ id: 'fairy-invite-already-has-access',
+ title: alreadyHasAccessMsg,
+ })
+ return
+ }
+
+ // Only show dialog if fairies are enabled
+ if (flags.fairies.enabled) {
+ dialogs.addDialog({
+ component: ({ onClose }) => (
+
+ ),
+ })
+ }
+ // If flags are disabled, token is cleaned up but dialog is not shown
+ }
+ }, [
+ auth.isLoaded,
+ auth.userId,
+ auth.isSignedIn,
+ dialogs,
+ flags.fairies.enabled,
+ isLoaded,
+ hasFairyAccess,
+ addToast,
+ alreadyHasAccessMsg,
+ ])
+
+ return null
+}
+
function SignedInProvider({
children,
onThemeChange,
@@ -197,7 +260,6 @@ function SignedInProvider({
() => globalEditor.get()?.user.getUserPreferences().locale ?? 'en',
[]
)
-
useEffect(() => {
if (locale === currentLocale) return
onLocaleChange(locale)
@@ -232,6 +294,7 @@ function SignedInProvider({
if (!auth.isSignedIn || !user || !isUserLoaded) {
return (
+
{children}
@@ -242,6 +305,7 @@ function SignedInProvider({
+
{children}
diff --git a/apps/dotcom/client/src/tla/utils/FeatureFlagsFetcher.tsx b/apps/dotcom/client/src/tla/utils/FeatureFlagsFetcher.tsx
new file mode 100644
index 000000000000..ccab0bdb3c27
--- /dev/null
+++ b/apps/dotcom/client/src/tla/utils/FeatureFlagsFetcher.tsx
@@ -0,0 +1,52 @@
+import { FeatureFlagKey, FeatureFlagValue } from '@tldraw/dotcom-shared'
+import { useEffect } from 'react'
+import { Atom, atom, fetch } from 'tldraw'
+
+type FeatureFlags = Record
+
+// Global atom for feature flags
+export const featureFlagsAtom: Atom = atom('featureFlags', {
+ fairies: { enabled: false, description: '' },
+ fairies_purchase: { enabled: false, description: '' },
+})
+
+// Atom to track if flags have been loaded at least once
+export const featureFlagsLoadedAtom: Atom = atom('featureFlagsLoaded', false)
+
+const REFETCH_INTERVAL = 60000 // 1 minute
+
+export function FeatureFlagsFetcher() {
+ useEffect(() => {
+ let mounted = true
+
+ async function fetchFlags() {
+ try {
+ const response = await fetch('/api/app/feature-flags')
+ if (!response.ok) {
+ console.error('Failed to fetch feature flags:', response.statusText)
+ return
+ }
+ const data = await response.json()
+ if (mounted) {
+ featureFlagsAtom.set(data)
+ featureFlagsLoadedAtom.set(true)
+ }
+ } catch (error) {
+ console.error('Error fetching feature flags:', error)
+ }
+ }
+
+ // Initial fetch
+ fetchFlags()
+
+ // Poll every minute
+ const interval = setInterval(fetchFlags, REFETCH_INTERVAL)
+
+ return () => {
+ mounted = false
+ clearInterval(interval)
+ }
+ }, [])
+
+ return null
+}
diff --git a/apps/dotcom/sync-worker/src/TLUserDurableObject.ts b/apps/dotcom/sync-worker/src/TLUserDurableObject.ts
index 5ea72707ab8d..ce2da407f14e 100644
--- a/apps/dotcom/sync-worker/src/TLUserDurableObject.ts
+++ b/apps/dotcom/sync-worker/src/TLUserDurableObject.ts
@@ -27,6 +27,7 @@ import { Logger } from './Logger'
import { UserDataSyncer, ZReplicationEvent } from './UserDataSyncer'
import { Analytics, Environment, TLUserDurableObjectEvent, getUserDoSnapshotKey } from './types'
import { EventData, writeDataPoint } from './utils/analytics'
+import { getFeatureFlag } from './utils/featureFlags'
import { isRateLimited } from './utils/rateLimit'
import { retryOnConnectionFailure } from './utils/retryOnConnectionFailure'
import { getClerkClient } from './utils/tla/getAuth'
@@ -234,6 +235,11 @@ export class TLUserDurableObject extends DurableObject {
return Response.json({ error: 'User ID not initialized' }, { status: 400 })
}
+ const flagEnabled = await getFeatureFlag(this.env, 'fairies')
+ if (!flagEnabled) {
+ return Response.json({ hasAccess: false })
+ }
+
const userFairies = await this.db
.selectFrom('user_fairies')
.where('userId', '=', this.userId)
diff --git a/apps/dotcom/sync-worker/src/adminRoutes.ts b/apps/dotcom/sync-worker/src/adminRoutes.ts
index f7811342b80d..27923cfc3603 100644
--- a/apps/dotcom/sync-worker/src/adminRoutes.ts
+++ b/apps/dotcom/sync-worker/src/adminRoutes.ts
@@ -1,4 +1,4 @@
-import { MAX_FAIRY_COUNT, TlaFile } from '@tldraw/dotcom-shared'
+import { FeatureFlagKey, MAX_FAIRY_COUNT, TlaFile } from '@tldraw/dotcom-shared'
import { assert, retry, sleep, uniqueId } from '@tldraw/utils'
import { createRouter } from '@tldraw/worker-shared'
import { StatusError, json } from 'itty-router'
@@ -8,6 +8,7 @@ import { createPostgresConnectionPool } from './postgres'
import { returnFileSnapshot } from './routes/tla/getFileSnapshot'
import { type Environment } from './types'
import { getReplicator, getRoomDurableObject, getUserDurableObject } from './utils/durableObjects'
+import { getFeatureFlags, setFeatureFlag } from './utils/featureFlags'
import { getClerkClient, requireAdminAccess, requireAuth } from './utils/tla/getAuth'
async function requireUser(env: Environment, q: string) {
@@ -225,26 +226,34 @@ export const adminRoutes = createRouter()
.post('/app/admin/fairy-invites', async (req, env) => {
const body: any = await req.json()
const maxUses = body?.maxUses
+ const description = body?.description ?? null
if (typeof maxUses !== 'number' || maxUses < 0) {
return new Response('maxUses must be 0 (unlimited) or a positive number', { status: 400 })
}
+ if (description !== null && typeof description !== 'string') {
+ return new Response('description must be a string or null', { status: 400 })
+ }
+
const db = createPostgresConnectionPool(env, '/app/admin/fairy-invites')
const id = uniqueId()
-
- await db
- .insertInto('fairy_invite')
- .values({
- id,
- fairyLimit: MAX_FAIRY_COUNT,
- maxUses,
- currentUses: 0,
- createdAt: Date.now(),
- })
- .execute()
-
- return json({ id, fairyLimit: MAX_FAIRY_COUNT, maxUses, currentUses: 0, createdAt: Date.now() })
+ const createdAt = Date.now()
+
+ await sql`
+ INSERT INTO fairy_invite (id, "fairyLimit", "maxUses", "currentUses", "createdAt", description, "redeemedBy")
+ VALUES (${id}, ${MAX_FAIRY_COUNT}, ${maxUses}, 0, ${createdAt}, ${description}, '[]'::jsonb)
+ `.execute(db)
+
+ return json({
+ id,
+ fairyLimit: MAX_FAIRY_COUNT,
+ maxUses,
+ currentUses: 0,
+ createdAt,
+ description,
+ redeemedBy: [],
+ })
})
.get('/app/admin/fairy-invites', async (_req, env) => {
const db = createPostgresConnectionPool(env, '/app/admin/fairy-invites')
@@ -286,6 +295,23 @@ export const adminRoutes = createRouter()
const result = await removeFairyAccess(env, email)
return json(result)
})
+ .get('/app/admin/feature-flags', getFeatureFlags)
+ .post('/app/admin/feature-flags', async (req, env) => {
+ const body: any = await req.json()
+ const { flag, enabled } = body
+
+ if (typeof flag !== 'string' || typeof enabled !== 'boolean') {
+ throw new StatusError(400, 'flag (string) and enabled (boolean) are required')
+ }
+
+ const validFlags: FeatureFlagKey[] = ['fairies', 'fairies_purchase']
+ if (!validFlags.includes(flag as FeatureFlagKey)) {
+ throw new StatusError(400, `Invalid flag. Must be one of: ${validFlags.join(', ')}`)
+ }
+
+ await setFeatureFlag(env, flag as FeatureFlagKey, enabled)
+ return json({ success: true, flag, enabled })
+ })
.post('/app/admin/create_legacy_file', async (_res, env) => {
const slug = uniqueId()
await getRoomDurableObject(env, slug).__admin__createLegacyRoom(slug)
diff --git a/apps/dotcom/sync-worker/src/routes/tla/redeemFairyInvite.ts b/apps/dotcom/sync-worker/src/routes/tla/redeemFairyInvite.ts
index 661d1cdf6c52..1ce6ba71e440 100644
--- a/apps/dotcom/sync-worker/src/routes/tla/redeemFairyInvite.ts
+++ b/apps/dotcom/sync-worker/src/routes/tla/redeemFairyInvite.ts
@@ -1,11 +1,18 @@
import { hasActiveFairyAccess } from '@tldraw/dotcom-shared'
import { IRequest, StatusError, json } from 'itty-router'
+import { sql } from 'kysely'
import { upsertFairyAccess } from '../../adminRoutes'
import { createPostgresConnectionPool } from '../../postgres'
import { Environment } from '../../types'
-import { requireAuth } from '../../utils/tla/getAuth'
+import { getFeatureFlag } from '../../utils/featureFlags'
+import { getClerkClient, requireAuth } from '../../utils/tla/getAuth'
export async function redeemFairyInvite(request: IRequest, env: Environment): Promise {
+ const fairiesEnabled = await getFeatureFlag(env, 'fairies')
+ if (!fairiesEnabled) {
+ throw new StatusError(403, 'Fairy invites are currently disabled')
+ }
+
const auth = await requireAuth(request, env)
const body: any = await request.json()
const inviteCode = body?.inviteCode
@@ -14,6 +21,15 @@ export async function redeemFairyInvite(request: IRequest, env: Environment): Pr
throw new StatusError(400, 'Invalid invite code')
}
+ // Get user's email from Clerk
+ const clerkClient = getClerkClient(env)
+ const clerkUser = await clerkClient.users.getUser(auth.userId)
+ const userEmail = clerkUser.emailAddresses[0]?.emailAddress
+
+ if (!userEmail) {
+ throw new StatusError(400, 'User email not found')
+ }
+
const db = createPostgresConnectionPool(env, 'redeemFairyInvite')
try {
@@ -50,14 +66,14 @@ export async function redeemFairyInvite(request: IRequest, env: Environment): Pr
return null // Signal that user already has access
}
- // Increment the invite usage count
- await tx
- .updateTable('fairy_invite')
- .set({
- currentUses: invite.currentUses + 1,
- })
- .where('id', '=', inviteCode)
- .execute()
+ // Increment the invite usage count and append user email to redeemedBy
+ await sql`
+ UPDATE fairy_invite
+ SET
+ "currentUses" = "currentUses" + 1,
+ "redeemedBy" = COALESCE("redeemedBy", '[]'::jsonb) || jsonb_build_array(${userEmail}::text)
+ WHERE id = ${inviteCode}
+ `.execute(tx)
return invite
})
diff --git a/apps/dotcom/sync-worker/src/types.ts b/apps/dotcom/sync-worker/src/types.ts
index b46c97ced81d..ee030c7248ca 100644
--- a/apps/dotcom/sync-worker/src/types.ts
+++ b/apps/dotcom/sync-worker/src/types.ts
@@ -45,6 +45,8 @@ export interface Environment {
SLUG_TO_READONLY_SLUG: KVNamespace
READONLY_SLUG_TO_SLUG: KVNamespace
+ FEATURE_FLAGS: KVNamespace
+
CF_VERSION_METADATA: WorkerVersionMetadata
// env vars
diff --git a/apps/dotcom/sync-worker/src/utils/featureFlags.ts b/apps/dotcom/sync-worker/src/utils/featureFlags.ts
new file mode 100644
index 000000000000..41b8e6ac8585
--- /dev/null
+++ b/apps/dotcom/sync-worker/src/utils/featureFlags.ts
@@ -0,0 +1,94 @@
+import { FeatureFlagKey, FeatureFlagValue, hasActiveFairyAccess } from '@tldraw/dotcom-shared'
+import { IRequest } from 'itty-router'
+import { Environment } from '../types'
+
+function getFlagDefaults(env: Environment): Record {
+ // Default to enabled in dev/preview when no KV value exists
+ const defaultEnabled = env.TLDRAW_ENV === 'development'
+
+ return {
+ fairies: {
+ enabled: defaultEnabled,
+ description: 'When OFF: completely disables all fairy features for everyone',
+ },
+ fairies_purchase: {
+ enabled: defaultEnabled,
+ description: 'When OFF: hides purchase button (respects in-flight webhooks)',
+ },
+ }
+}
+
+const ALL_FLAGS: FeatureFlagKey[] = ['fairies', 'fairies_purchase']
+
+/**
+ * Get feature flag value from KV store
+ * @returns FeatureFlagValue with enabled status and description
+ */
+export async function getFeatureFlagValue(
+ env: Environment,
+ flag: FeatureFlagKey
+): Promise {
+ try {
+ const value = await env.FEATURE_FLAGS.get(flag)
+ if (!value) {
+ // Return environment-specific default if not found
+ return getFlagDefaults(env)[flag]
+ }
+ return JSON.parse(value)
+ } catch (e) {
+ console.error(`Failed to get feature flag ${flag}:`, e)
+ return getFlagDefaults(env)[flag]
+ }
+}
+
+/**
+ * Get feature flag enabled status (for backward compatibility)
+ * @returns true if enabled, false if disabled or not found (safe default)
+ */
+export async function getFeatureFlag(env: Environment, flag: FeatureFlagKey): Promise {
+ const value = await getFeatureFlagValue(env, flag)
+ return value.enabled
+}
+
+/**
+ * Set feature flag value in KV store
+ * Admin only - use via admin routes
+ */
+export async function setFeatureFlag(
+ env: Environment,
+ flag: FeatureFlagKey,
+ enabled: boolean
+): Promise {
+ const current = await getFeatureFlagValue(env, flag)
+ const updated: FeatureFlagValue = { ...current, enabled }
+ await env.FEATURE_FLAGS.put(flag, JSON.stringify(updated))
+}
+
+/**
+ * Check if user has fairy access (flag + existing checks)
+ */
+export async function checkFairyAccess(
+ env: Environment,
+ fairyLimit: number | null,
+ fairyAccessExpiresAt: number | null
+): Promise {
+ const flagEnabled = await getFeatureFlag(env, 'fairies')
+ if (!flagEnabled) return false
+
+ return hasActiveFairyAccess(fairyAccessExpiresAt, fairyLimit)
+}
+
+/**
+ * Route handler: Get all feature flags (full objects with descriptions)
+ */
+export async function getFeatureFlags(_request: IRequest, env: Environment): Promise {
+ const flags: Record = {}
+
+ await Promise.all(
+ ALL_FLAGS.map(async (key) => {
+ flags[key] = await getFeatureFlagValue(env, key)
+ })
+ )
+
+ return new Response(JSON.stringify(flags), { headers: { 'Content-Type': 'application/json' } })
+}
diff --git a/apps/dotcom/sync-worker/src/worker.ts b/apps/dotcom/sync-worker/src/worker.ts
index 4073f0ff844c..7b1d3b1187e4 100644
--- a/apps/dotcom/sync-worker/src/worker.ts
+++ b/apps/dotcom/sync-worker/src/worker.ts
@@ -50,6 +50,7 @@ import { upload } from './routes/tla/uploads'
import { testRoutes } from './testRoutes'
import { Environment, QueueMessage, isDebugLogging } from './types'
import { getLogger, getReplicator, getUserDurableObject } from './utils/durableObjects'
+import { getFeatureFlags } from './utils/featureFlags'
import { getAuth, requireAuth } from './utils/tla/getAuth'
export { TLDrawDurableObject } from './TLDrawDurableObject'
export { TLLoggerDurableObject } from './TLLoggerDurableObject'
@@ -159,6 +160,7 @@ const router = createRouter()
return new Response('Not Found', { status: 404 })
})
.post('/app/submit-feedback', submitFeedback)
+ .get('/app/feature-flags', getFeatureFlags)
// end app
.all('/ph/*', (req) => {
const url = new URL(req.url)
diff --git a/apps/dotcom/sync-worker/wrangler.toml b/apps/dotcom/sync-worker/wrangler.toml
index e5d77730f38e..607255a642a2 100644
--- a/apps/dotcom/sync-worker/wrangler.toml
+++ b/apps/dotcom/sync-worker/wrangler.toml
@@ -302,6 +302,25 @@ id = "2fb5fc7f7ca54a5a9dfae1b07a30a778"
binding = "READONLY_SLUG_TO_SLUG"
id = "96be6637b281412ab35b2544539d78e8"
+#################### Feature flags KV store ####################
+# Dev has its own namespace
+[[env.dev.kv_namespaces]]
+binding = "FEATURE_FLAGS"
+id = "81a6f84741754ac4a9171f575f2aeece"
+
+[[env.preview.kv_namespaces]]
+binding = "FEATURE_FLAGS"
+id = "81a6f84741754ac4a9171f575f2aeece"
+
+[[env.staging.kv_namespaces]]
+binding = "FEATURE_FLAGS"
+id = "7b7422a178e641b19a846bfec912e399"
+
+# Production has its own namespace
+[[env.production.kv_namespaces]]
+binding = "FEATURE_FLAGS"
+id = "cf1dc78773ec48e5a44b184fd4734094"
+
#################### Version metadata ####################
[version_metadata]
binding = "CF_VERSION_METADATA"
diff --git a/apps/dotcom/zero-cache/migrations/027_fairy_invite_description.sql b/apps/dotcom/zero-cache/migrations/027_fairy_invite_description.sql
new file mode 100644
index 000000000000..b5bf1029d846
--- /dev/null
+++ b/apps/dotcom/zero-cache/migrations/027_fairy_invite_description.sql
@@ -0,0 +1,3 @@
+ALTER TABLE fairy_invite
+ADD COLUMN description TEXT,
+ADD COLUMN "redeemedBy" JSONB DEFAULT '[]'::jsonb;
diff --git a/packages/dotcom-shared/src/tlaSchema.ts b/packages/dotcom-shared/src/tlaSchema.ts
index 91e1b0061c1d..1c409c055e07 100644
--- a/packages/dotcom-shared/src/tlaSchema.ts
+++ b/packages/dotcom-shared/src/tlaSchema.ts
@@ -309,6 +309,11 @@ export interface TlaUserFairyDB extends Omit {
weeklyUsage: Record // JSONB: { "2025-W48": 12.34 }
}
+// Override for fairy_invite with proper JSONB types for Kysely
+export interface TlaFairyInviteDB extends Omit {
+ redeemedBy: string[] // JSONB: ["email1@example.com", "email2@example.com"]
+}
+
export interface DB {
file: TlaFile
file_state: TlaFileState
@@ -318,7 +323,7 @@ export interface DB {
group_file: TlaGroupFile
user_fairies: TlaUserFairyDB
file_fairies: TlaFileFairy
- fairy_invite: TlaFairyInvite
+ fairy_invite: TlaFairyInviteDB
user_mutation_number: TlaUserMutationNumber
asset: TlaAsset
file_fairy_messages: TlaFileFairyMessage
@@ -362,6 +367,8 @@ export interface TlaFairyInvite {
maxUses: number
currentUses: number
createdAt: number
+ description: string | null
+ redeemedBy: string[] // Array of emails
}
interface AuthData {
diff --git a/packages/dotcom-shared/src/types.ts b/packages/dotcom-shared/src/types.ts
index 25ba5900173e..7ae960ccb95f 100644
--- a/packages/dotcom-shared/src/types.ts
+++ b/packages/dotcom-shared/src/types.ts
@@ -230,6 +230,13 @@ export type TLCustomServerEvent = { type: 'persistence_good' } | { type: 'persis
/* ----------------------- Fairy Access ---------------------- */
+export type FeatureFlagKey = 'fairies' | 'fairies_purchase'
+
+export interface FeatureFlagValue {
+ enabled: boolean
+ description: string
+}
+
export function hasActiveFairyAccess(
fairyAccessExpiresAt: number | null,
fairyLimit: number | null