Skip to content

Commit 3f5c127

Browse files
committed
feat: SaaS analytics infrastructure — PostHog events, feature flags, surveys, billing, referral, edge functions
- Add WAITLIST_JOINED and UPGRADE_LIMIT_HIT analytics events - Wire events into ProComingSoonPage and VisualizerApp - Dynamic anonymous viz limit via PostHog feature flag - Add posthog-js mock to test setup - 6 PostHog feature flags (anon-viz-limit, onboarding, upgrade-modal, waitlist-social-proof, recommendations, referral) - 4 PostHog surveys (exit, feature-request, NPS, pro-interest) - DB migrations: user_sessions, billing_subscriptions, referrals - Edge functions: sync-contacts (Resend), lemonqueezy-webhook (billing) - CSP updates for LemonSqueezy domains - Supabase MCP deploys for both new edge functions
1 parent 2d6e8d3 commit 3f5c127

16 files changed

Lines changed: 697 additions & 6 deletions

public/_headers

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Referrer-Policy: strict-origin-when-cross-origin
66
Permissions-Policy: camera=(), microphone=(), geolocation=(), identity-credentials-get=(self "https://accounts.google.com")
77
# Pyodide CDN origins: jsDelivr below; custom VITE_PYODIDE_CDN_BASE origins appended at build (vite.config.js)
8-
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com; object-src 'none'; base-uri 'self'; form-action 'self'
8+
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com https://app.lemonsqueezy.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com https://api.lemonsqueezy.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com https://app.lemonsqueezy.com; object-src 'none'; base-uri 'self'; form-action 'self'
99
Link: <https://bayanflow.com/sitemap.xml>; rel="sitemap"
1010
Link: </.well-known/api-catalog>; rel="api-catalog"
1111
Link: </.well-known/agent-card.json>; rel="service-desc"

scripts/cspHeaders.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,36 @@ export function assertAuthCspDirectives(csp, source) {
173173

174174
return { connectSrc: connectSrc ?? '', imgSrc: imgSrc ?? '' };
175175
}
176+
177+
/**
178+
* Assert CSP directives required for LemonSqueezy billing integration.
179+
* @param {string} csp
180+
* @param {string} source - Label for error messages (e.g. "public/_headers")
181+
* @returns {{ scriptSrc: string; connectSrc: string; frameSrc: string }}
182+
*/
183+
export function assertBillingCspDirectives(csp, source) {
184+
const directives = parseCspDirectives(csp);
185+
186+
const scriptSrc = directives.get('script-src') ?? '';
187+
if (!scriptSrc.includes('https://app.lemonsqueezy.com')) {
188+
throw new Error(
189+
`${source}: script-src must include https://app.lemonsqueezy.com (LemonSqueezy checkout)`
190+
);
191+
}
192+
193+
const connectSrc = directives.get('connect-src') ?? '';
194+
if (!connectSrc.includes('https://api.lemonsqueezy.com')) {
195+
throw new Error(
196+
`${source}: connect-src must include https://api.lemonsqueezy.com (LemonSqueezy API)`
197+
);
198+
}
199+
200+
const frameSrc = directives.get('frame-src') ?? '';
201+
if (!frameSrc.includes('https://app.lemonsqueezy.com')) {
202+
throw new Error(
203+
`${source}: frame-src must include https://app.lemonsqueezy.com (LemonSqueezy checkout overlay)`
204+
);
205+
}
206+
207+
return { scriptSrc, connectSrc, frameSrc };
208+
}

src/contexts/AuthProvider.jsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,31 @@ import {
1818
import { AuthContext } from './AuthContextDefinition';
1919
import { identifyUser, resetUser } from '../services/analytics';
2020
import { trackSignInCompleted } from '../services/analyticsEvents';
21+
import { getSupabaseClient } from '@/lib/supabaseClient';
2122

2223
/** @typedef {'account_banned' | null} AccessBlockReason */
2324

25+
/**
26+
* Sync user contact to Resend for email automation (fire-and-forget).
27+
* @param {object} user
28+
* @param {object} profile
29+
*/
30+
function syncContactToResend(user, profile) {
31+
const supabase = getSupabaseClient();
32+
if (!supabase) return;
33+
34+
void supabase.functions.invoke('sync-contacts', {
35+
method: 'POST',
36+
body: {
37+
email: user.email,
38+
userId: user.id,
39+
plan: profile?.plan || 'free',
40+
displayName: profile?.displayName || '',
41+
language: document.documentElement.lang,
42+
},
43+
});
44+
}
45+
2446
/**
2547
* @param {import('@supabase/supabase-js').User | null} user
2648
* @param {Awaited<ReturnType<typeof getProfile>>} profileRow
@@ -194,6 +216,11 @@ export function AuthProvider({ children }) {
194216
plan: null,
195217
});
196218
trackSignInCompleted();
219+
syncContactToResend(nextSession.user, {
220+
displayName:
221+
nextSession.user.user_metadata?.full_name ||
222+
nextSession.user.user_metadata?.name,
223+
});
197224
}
198225

199226
if (event === 'SIGNED_OUT') {

src/pages/ProComingSoonPage.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
joinWaitlist,
2828
readStoredWaitlistEmail,
2929
} from '@/services/waitlistService';
30+
import { trackWaitlistJoined } from '@/services/analyticsEvents';
3031

3132
const FEATURE_ITEMS = [
3233
{ key: 'customInput', Icon: Sliders },
@@ -104,6 +105,7 @@ function ProComingSoonPage() {
104105
if (result.status === 'joined') {
105106
setPosition(result.position ?? null);
106107
setSubmitState('success');
108+
trackWaitlistJoined(source, result.position);
107109
return;
108110
}
109111

src/pages/VisualizerApp.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
trackInsightPanelOpened,
8484
trackVideoExportStarted,
8585
trackCategoryChanged,
86+
trackUpgradeLimitHit,
8687
} from '../services/analyticsEvents';
8788

8889
const SOUND_PREFERENCE_STORAGE_KEY = 'bayan-flow:sound-enabled';
@@ -418,6 +419,7 @@ function App() {
418419
/** Play handler with session limit check for anonymous users */
419420
const handlePlay = () => {
420421
if (!canRunVisualization(user)) {
422+
trackUpgradeLimitHit(ANONYMOUS_VISUALIZATION_LIMIT);
421423
openGatedFeature('session_limit', {
422424
limit: ANONYMOUS_VISUALIZATION_LIMIT,
423425
});

src/security/cspHeaders.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
assertAnalyticsCspDirectives,
1313
assertAuthCspDirectives,
1414
assertVideoExportCspDirectives,
15+
assertBillingCspDirectives,
1516
extractCspFromHeadersFile,
1617
parseCspDirectives,
1718
} from '../../scripts/cspHeaders.js';
@@ -45,4 +46,8 @@ describe('CSP security headers', () => {
4546
it('public/_headers allows PostHog analytics', () => {
4647
assertAnalyticsCspDirectives(headersCsp, 'public/_headers');
4748
});
49+
50+
it('public/_headers allows LemonSqueezy billing', () => {
51+
assertBillingCspDirectives(headersCsp, 'public/_headers');
52+
});
4853
});

src/services/analytics.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function initPostHog() {
4444
api_host: host,
4545
ui_host: 'https://us.posthog.com',
4646
defaults: '2026-05-30',
47+
capture_pageview: 'history_change',
4748
autocapture: false,
4849
disable_surveys: true,
4950
capture_performance: true,

src/services/analyticsEvents.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export const UPGRADE_MODAL_VIEWED = 'upgrade_modal_viewed';
7070
/** Upgrade button clicked */
7171
export const UPGRADE_CLICKED = 'upgrade_clicked';
7272

73+
/** Waitlist form submitted successfully */
74+
export const WAITLIST_JOINED = 'waitlist_joined';
75+
76+
/** Anonymous user hit visualization limit (sign-in prompt triggered) */
77+
export const UPGRADE_LIMIT_HIT = 'upgrade_limit_hit';
78+
7379
// ─── Navigation ───────────────────────────────────────────────────────────────
7480

7581
/** Category tab changed */
@@ -256,3 +262,20 @@ export function trackCategoryChanged(fromCategory, toCategory) {
256262
to_category: toCategory,
257263
});
258264
}
265+
266+
/**
267+
* Capture a waitlist joined event.
268+
* @param {string} source
269+
* @param {number} [position]
270+
*/
271+
export function trackWaitlistJoined(source, position) {
272+
captureEvent(WAITLIST_JOINED, { source, position });
273+
}
274+
275+
/**
276+
* Capture an upgrade limit hit event (anonymous viz limit reached).
277+
* @param {number} limit
278+
*/
279+
export function trackUpgradeLimitHit(limit) {
280+
captureEvent(UPGRADE_LIMIT_HIT, { limit });
281+
}

src/services/entitlementService.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,28 @@ import {
1313
PRO_TIER_FAVORITE_SLOT_LIMIT,
1414
} from '@/constants/personalLearning';
1515
import { DEFAULT_VIDEO_WATERMARK } from '@/video/constants';
16+
import { getFeatureFlagVariant } from './featureFlags';
1617

17-
// Session limits for anonymous users
18-
export const ANONYMOUS_VISUALIZATION_LIMIT = 12;
18+
// Default session limits for anonymous users
19+
const DEFAULT_ANONYMOUS_VISUALIZATION_LIMIT = 12;
1920
const ANONYMOUS_COMPLEXITY_VIEW_LIMIT = 2;
2021
const FREE_TIER_DAILY_EXPORT_LIMIT = 50;
2122

23+
/**
24+
* Get the anonymous visualization limit, considering PostHog feature flag.
25+
* @returns {number}
26+
*/
27+
function getAnonymousVisualizationLimit() {
28+
const variant = getFeatureFlagVariant('feature-anonymous-viz-limit');
29+
if (variant === 'limit-8') return 8;
30+
if (variant === 'limit-16') return 16;
31+
return DEFAULT_ANONYMOUS_VISUALIZATION_LIMIT;
32+
}
33+
34+
// Exported constant for backward compatibility (reads once at module load)
35+
export const ANONYMOUS_VISUALIZATION_LIMIT =
36+
DEFAULT_ANONYMOUS_VISUALIZATION_LIMIT;
37+
2238
// localStorage keys
2339
const STORAGE_KEY_VIZ_COUNT = 'anon_viz_count';
2440
const STORAGE_KEY_COMPLEXITY_COUNT = 'anon_complexity_views';
@@ -169,12 +185,12 @@ export function canRunVisualization(user) {
169185
if (plan === PLAN_TIERS.FREE || plan === PLAN_TIERS.PRO) {
170186
return true; // Unlimited
171187
}
172-
// Anonymous: check session limit
188+
// Anonymous: check session limit (dynamic from feature flag)
173189
const count = parseInt(
174190
localStorage.getItem(STORAGE_KEY_VIZ_COUNT) || '0',
175191
10
176192
);
177-
return count < ANONYMOUS_VISUALIZATION_LIMIT;
193+
return count < getAnonymousVisualizationLimit();
178194
}
179195

180196
/**
@@ -204,7 +220,7 @@ export function getRemainingVisualizations(user) {
204220
localStorage.getItem(STORAGE_KEY_VIZ_COUNT) || '0',
205221
10
206222
);
207-
return Math.max(0, ANONYMOUS_VISUALIZATION_LIMIT - count);
223+
return Math.max(0, getAnonymousVisualizationLimit() - count);
208224
}
209225

210226
/**

src/test/setup.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,26 @@ const gridHelpersMock = {
287287
vi.mock('../utils/gridHelpers', () => gridHelpersMock);
288288
vi.mock('../utils/gridHelpers.js', () => gridHelpersMock);
289289

290+
vi.mock('posthog-js', () => ({
291+
default: {
292+
init: vi.fn(),
293+
identify: vi.fn(),
294+
capture: vi.fn(),
295+
reset: vi.fn(),
296+
setPersonProperties: vi.fn(),
297+
isFeatureEnabled: vi.fn(() => false),
298+
getFeatureFlagVariant: vi.fn(() => undefined),
299+
getFeatureFlagPayload: vi.fn(() => undefined),
300+
onFeatureFlags: vi.fn(() => vi.fn()),
301+
get_distinct_id: vi.fn(() => 'test-distinct-id'),
302+
opt_out_capturing: vi.fn(),
303+
has_opted_out_capturing: vi.fn(() => false),
304+
opt_in_capturing: vi.fn(),
305+
reloadFeatureFlags: vi.fn(),
306+
shutdown: vi.fn(),
307+
},
308+
}));
309+
290310
vi.mock('../lib/supabaseClient', () => import('./supabaseMock.js'));
291311
vi.mock('../lib/supabaseClient.js', () => import('./supabaseMock.js'));
292312

0 commit comments

Comments
 (0)