diff --git a/apps/dotcom/client/public/tla/locales-compiled/en.json b/apps/dotcom/client/public/tla/locales-compiled/en.json index 5591bcc8117c..7caae670c6b0 100644 --- a/apps/dotcom/client/public/tla/locales-compiled/en.json +++ b/apps/dotcom/client/public/tla/locales-compiled/en.json @@ -11,6 +11,12 @@ "value": "Accept invitation" } ], + "01d61b2012": [ + { + "type": 0, + "value": "Start your fairy adventure" + } + ], "03b3e13197": [ { "type": 0, @@ -945,6 +951,12 @@ "value": "Check your email for a verification code." } ], + "8cf04a9734": [ + { + "type": 0, + "value": "Home" + } + ], "8eb2c00e96": [ { "type": 0, @@ -1559,6 +1571,12 @@ "value": "Legal summary" } ], + "e2b0147fd2": [ + { + "type": 0, + "value": "Your fairies are waiting for you! →" + } + ], "e3afed0047": [ { "type": 0, diff --git a/apps/dotcom/client/public/tla/locales/en.json b/apps/dotcom/client/public/tla/locales/en.json index a713491b2c11..80bc99dc84b4 100644 --- a/apps/dotcom/client/public/tla/locales/en.json +++ b/apps/dotcom/client/public/tla/locales/en.json @@ -5,6 +5,9 @@ "00b177e080": { "translation": "Accept invitation" }, + "01d61b2012": { + "translation": "Start your fairy adventure" + }, "03b3e13197": { "translation": "Drag the file into the sidebar on this page. Or select the 'Import file' option from the user menu." }, @@ -422,6 +425,9 @@ "8c2d3d730c": { "translation": "Check your email for a verification code." }, + "8cf04a9734": { + "translation": "Home" + }, "8eb2c00e96": { "translation": "Shared with you" }, @@ -672,6 +678,9 @@ "e2ae80a510": { "translation": "Legal summary" }, + "e2b0147fd2": { + "translation": "Your fairies are waiting for you! →" + }, "e3afed0047": { "translation": "Admin" }, diff --git a/apps/dotcom/client/src/pages/admin.tsx b/apps/dotcom/client/src/pages/admin.tsx index 22a9f95baec9..ced17bec0197 100644 --- a/apps/dotcom/client/src/pages/admin.tsx +++ b/apps/dotcom/client/src/pages/admin.tsx @@ -1,4 +1,11 @@ -import { MAX_FAIRY_COUNT, TlaFile, TlaUser, userHasFlag, ZStoreData } from '@tldraw/dotcom-shared' +import { + FeatureFlagValue, + MAX_FAIRY_COUNT, + TlaFile, + TlaUser, + userHasFlag, + ZStoreData, +} from '@tldraw/dotcom-shared' import { RefObject, useCallback, useEffect, useRef, useState } from 'react' import { Navigate } from 'react-router-dom' import { fetch } from 'tldraw' @@ -241,6 +248,12 @@ export function Component() { + {/* Feature Flags Section */} +
+

Feature Flags

+ +
+ {/* File Operations Section */}

File Operations

@@ -270,12 +283,13 @@ function FairyInvites() { maxUses: number currentUses: number createdAt: number + description: string | null + redeemedBy: string[] }> >([]) const [maxUses, setMaxUses] = useState(1) - const [grantEmail, setGrantEmail] = useState('') - const [grantSetToZero, setGrantSetToZero] = useState(false) - const [removeEmail, setRemoveEmail] = useState('') + const [inviteDescription, setInviteDescription] = useState('') + const [accessEmail, setAccessEmail] = useState('') const [isCreating, setIsCreating] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isEnabling, setIsEnabling] = useState(false) @@ -318,7 +332,7 @@ function FairyInvites() { const res = await fetch('/api/app/admin/fairy-invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ maxUses }), + body: JSON.stringify({ maxUses, description: inviteDescription || null }), }) if (!res.ok) { setError(res.statusText + ': ' + (await res.text())) @@ -326,13 +340,14 @@ function FairyInvites() { } const invite = await res.json() setSuccessMessage(`Invite created: ${invite.id}`) + setInviteDescription('') await loadInvites() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create invite') } finally { setIsCreating(false) } - }, [maxUses, loadInvites]) + }, [maxUses, inviteDescription, loadInvites]) const deleteInvite = useCallback( async (id: string) => { @@ -360,7 +375,7 @@ function FairyInvites() { ) const grantAccess = useCallback(async () => { - if (!grantEmail || !grantEmail.includes('@')) { + if (!accessEmail || !accessEmail.includes('@')) { setError('Please enter a valid email address') return } @@ -372,21 +387,21 @@ function FairyInvites() { const res = await fetch('/api/app/admin/fairy/grant-access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: grantEmail, setToZero: grantSetToZero }), + body: JSON.stringify({ email: accessEmail }), }) if (!res.ok) { setError(res.statusText + ': ' + (await res.text())) return } await res.json() - setSuccessMessage(`Fairy access granted to ${grantEmail}!`) - setGrantEmail('') + setSuccessMessage(`Fairy access granted to ${accessEmail}!`) + setAccessEmail('') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to grant fairy access') } finally { setIsGranting(false) } - }, [grantEmail, grantSetToZero]) + }, [accessEmail]) const enableForMe = useCallback(async () => { setIsEnabling(true) @@ -410,11 +425,15 @@ function FairyInvites() { }, []) const removeFairyAccess = useCallback(async () => { - if (!removeEmail || !removeEmail.includes('@')) { + if (!accessEmail || !accessEmail.includes('@')) { setError('Please enter a valid email address') return } + if (!window.confirm(`Remove fairy access from ${accessEmail}?`)) { + return + } + setIsRemoving(true) setError(null) setSuccessMessage(null) @@ -422,21 +441,21 @@ function FairyInvites() { const res = await fetch('/api/app/admin/fairy/remove-access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: removeEmail }), + body: JSON.stringify({ email: accessEmail }), }) if (!res.ok) { setError(res.statusText + ': ' + (await res.text())) return } await res.json() - setSuccessMessage(`Fairy access removed from ${removeEmail}!`) - setRemoveEmail('') + setSuccessMessage(`Fairy access removed from ${accessEmail}!`) + setAccessEmail('') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove fairy access') } finally { setIsRemoving(false) } - }, [removeEmail]) + }, [accessEmail]) useEffect(() => { if (successMessage) { @@ -461,65 +480,32 @@ function FairyInvites() { -

Grant Fairy Access to User

+

Manage Fairy Access

- Grant fairy access to a user by email. This will grant {MAX_FAIRY_COUNT} fairies, or use the - checkbox to set to 0 (shows purchase option). -

-
-
-
- - setGrantEmail(e.target.value)} - className={styles.searchInput} - style={{ width: '250px', marginLeft: '8px' }} - /> -
- - Grant Access - -
-
- -
-
- -

Remove Fairy Access from User

-

- Remove fairy access from a user by email. This will set fairy limit and expiration to null. + Grant or remove fairy access by email. Granting access will give {MAX_FAIRY_COUNT} fairies.

- + setRemoveEmail(e.target.value)} + value={accessEmail} + onChange={(e) => setAccessEmail(e.target.value)} className={styles.searchInput} style={{ width: '250px', marginLeft: '8px' }} />
- + + Grant Access + + Remove Access
@@ -530,6 +516,18 @@ function FairyInvites() { redeem this code (0 = unlimited).

+
+ + setInviteDescription(e.target.value)} + className={styles.searchInput} + style={{ width: '200px', marginLeft: '8px' }} + /> +
ID + Description Fairy Limit Uses + Redeemed By Created Actions @@ -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 ( +
+ + {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() {

-
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