Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getServerSession } from 'next-auth/next';
import { handleTrendingRedirect } from '@/utils/navigation';
import { LandingPage } from '@/components/landing/LandingPage';
import { headers } from 'next/headers';
import { ExperimentVariant } from '@/utils/experiment';

export default async function Home({
searchParams,
Expand All @@ -9,6 +11,9 @@ export default async function Home({
}) {
const session = await getServerSession();

const headersList = await headers();
const homepageExperimentVariant = headersList.get('x-homepage-experiment');

const resolvedSearchParams = await searchParams;

const urlSearchParams = new URLSearchParams();
Expand All @@ -22,7 +27,11 @@ export default async function Home({
}
});

handleTrendingRedirect(!!session?.user, urlSearchParams);
handleTrendingRedirect(
!!session?.user,
urlSearchParams,
homepageExperimentVariant as ExperimentVariant | null
);

return <LandingPage />;
}
22 changes: 21 additions & 1 deletion components/Auth/screens/SelectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useAutoFocus } from '@/hooks/useAutoFocus';
import AnalyticsService, { LogEvent } from '@/services/analytics.service';
import { useReferral } from '@/contexts/ReferralContext';
import { Button } from '@/components/ui/Button';
import { Experiment, getHomepageExperimentVariant } from '@/utils/experiment';

interface SelectProviderProps {
onContinue: () => void;
Expand Down Expand Up @@ -48,7 +49,18 @@ export default function SelectProvider({

if (response.exists) {
if (response.auth === 'google') {
signIn('google', { callbackUrl: '/' });
const originalCallbackUrl = '/';
let finalCallbackUrl = originalCallbackUrl;

const experimentVariant = getHomepageExperimentVariant();
if (experimentVariant) {
// Create URL with experiment parameter
const experimentUrl = new URL(originalCallbackUrl, window.location.origin);
experimentUrl.searchParams.set(Experiment.HomepageExperiment, experimentVariant);
finalCallbackUrl = experimentUrl.toString();
}

signIn('google', { callbackUrl: finalCallbackUrl });
} else if (response.is_verified) {
onContinue();
} else {
Expand All @@ -74,6 +86,14 @@ export default function SelectProvider({

let finalCallbackUrl = originalCallbackUrl;

const homepageExperimentVariant = getHomepageExperimentVariant();
if (homepageExperimentVariant) {
// Create URL with experiment parameter
const experimentUrl = new URL(originalCallbackUrl, window.location.origin);
experimentUrl.searchParams.set(Experiment.HomepageExperiment, homepageExperimentVariant);
finalCallbackUrl = experimentUrl.toString();
}

if (referralCode) {
// Create referral application URL with referral code and redirect as URL parameters
const referralUrl = new URL('/referral/join/apply-referral-code', window.location.origin);
Expand Down
7 changes: 6 additions & 1 deletion components/Auth/screens/Signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { faChevronLeft } from '@fortawesome/pro-light-svg-icons';
import { Button } from '@/components/ui/Button';
import { useReferral } from '@/contexts/ReferralContext';
import AnalyticsService from '@/services/analytics.service';
import { Experiment, ExperimentVariant, isExperimentEnabled } from '@/utils/experiment';

interface Props extends BaseScreenProps {
onBack: () => void;
Expand Down Expand Up @@ -60,7 +61,11 @@ export default function Signup({

await AuthService.register(registrationData);

AnalyticsService.logSignedUp('credentials');
AnalyticsService.logSignedUp('credentials', {
homepage_experiment: isExperimentEnabled(Experiment.HomepageExperiment)
? ExperimentVariant.B
: ExperimentVariant.A,
});

onVerify();
} catch (err) {
Expand Down
10 changes: 9 additions & 1 deletion components/modals/SignupModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react';
import SignupPromoModal from '@/components/modals/SignupPromoModal';
import AnalyticsService, { LogEvent } from '@/services/analytics.service';
import { usePathname } from 'next/navigation';
import { isExperimentEnabled, Experiment } from '@/utils/experiment';

const EXCLUDED_PATHS = [
'/', // landing page
Expand All @@ -20,9 +21,16 @@ export default function SignupModalContainer() {

useEffect(() => {
const modalDismissed = sessionStorage.getItem('signupModalDismissed') === 'true';
const isHomepageExperimentEnabled = isExperimentEnabled(Experiment.HomepageExperiment);
const isExcludedPath = EXCLUDED_PATHS.some((path) => pathname.startsWith(path));

if (status === 'unauthenticated' && !modalDismissed && pathname !== '/' && !isExcludedPath) {
if (
status === 'unauthenticated' &&
!modalDismissed &&
pathname !== '/' &&
!isExcludedPath &&
isHomepageExperimentEnabled
) {
const timer = setTimeout(() => {
setShowModal(true);
AnalyticsService.logEvent(LogEvent.SIGNUP_PROMO_MODAL_OPENED);
Expand Down
26 changes: 25 additions & 1 deletion contexts/UserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AuthError, AuthService } from '@/services/auth.service';
import type { User } from '@/types/user';
import { AuthSharingService } from '@/services/auth-sharing.service';
import AnalyticsService from '@/services/analytics.service';
import { Experiment } from '@/utils/experiment';

interface UserContextType {
user: User | null;
Expand Down Expand Up @@ -71,11 +72,34 @@ export function UserProvider({ children }: { children: ReactNode }) {

// Track Google OAuth sign-up if applicable
if (user.authProvider === 'google' && user.hasCompletedOnboarding === false) {
AnalyticsService.logSignedUp('google', {});
const urlParams = new URLSearchParams(window.location.search);
const urlHPExperimentVariant = urlParams.get(Experiment.HomepageExperiment);

if (urlHPExperimentVariant) {
/**
* Due to authentication flow limitations:
*
* 1. Credentials/Email flow: Track signed_up event immediately after client-side registration
* (cookies accessible in Signup.tsx component)
*
* 2. Google OAuth: Track during user context initialization by checking authProvider === 'google'
* (cookies accessible after redirect from Google OAuth)
*
* We track here instead of in SelectProvider because:
* - Google OAuth redirects to a new page, losing the original context
* - The experiment variant is passed via URL parameter and needs to be captured after redirect
* - UserContext is the first place where we have both user data and access to URL parameters
* - This ensures we capture the experiment variant before it gets cleaned up
*/
AnalyticsService.logSignedUp('google', {
homepage_experiment: urlHPExperimentVariant,
});
}
}

// Clean up URL parameter
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete(Experiment.HomepageExperiment);
window.history.replaceState({}, '', newUrl.toString());

// Mark analytics as initialized for this user
Expand Down
43 changes: 42 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,49 @@
import { NextRequestWithAuth, withAuth } from 'next-auth/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { Experiment, ExperimentVariant } from './utils/experiment';

function getExperimentVariant(request: NextRequest): string {
// Check if user already has an experiment assignment
const existingVariant = request.cookies.get(Experiment.HomepageExperiment);

if (existingVariant?.value) {
return existingVariant.value;
}

// Assign new variant (50/50 split)
// NOSONAR - Math.random is safe for A/B test assignment (non-security context)
const variant = Math.random() < 0.5 ? ExperimentVariant.A : ExperimentVariant.B;

return variant;
}

function setExperimentCookie(response: NextResponse, variant: string): void {
const expirationDate = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); // 1 day in milliseconds

response.cookies.set(Experiment.HomepageExperiment, variant, {
expires: expirationDate,
secure: process.env.NEXT_PUBLIC_VERCEL_ENV !== undefined,
sameSite: 'lax',
path: '/',
});
}

// Main middleware function
export function middleware(request: NextRequest) {
// Only apply A/B testing to the homepage for logged-out users
if (request.nextUrl.pathname === '/') {
const variant = getExperimentVariant(request);
const response = NextResponse.next();

// Set experiment cookie
setExperimentCookie(response, variant);

// Add experiment variant to headers for client-side access
response.headers.set('x-homepage-experiment', variant);

return response;
}

// For protected routes, use auth middleware
return withAuth(request as NextRequestWithAuth, {
callbacks: {
Expand All @@ -12,5 +53,5 @@ export function middleware(request: NextRequest) {
}

export const config = {
matcher: ['/notebook/:path*', '/notebook/api/:path*', '/referral', '/lists', '/list/:path*'],
matcher: ['/', '/notebook/:path*', '/notebook/api/:path*', '/referral', '/lists', '/list/:path*'],
};
56 changes: 56 additions & 0 deletions utils/experiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Cookies from 'js-cookie';

export enum Experiment {
HomepageExperiment = 'homepage_experiment',
}

// Experiment variants
export enum ExperimentVariant {
A = 'A', // Control group (Current version)
B = 'B', // Treatment group
}

/**
* Get the homepage experiment from the cookie.
*
* @returns The experiment variant or null if not set.
*/
export function getHomepageExperimentVariant(): string | null {
if (typeof document === 'undefined') return null;

return Cookies.get(Experiment.HomepageExperiment) || null;
}

/**
* Experiments for the application.
*
* Each experiment is a function that determines if the experiment is enabled.
* Centralize all experiment logic here.
*/
export const Experiments: Record<Experiment, () => boolean> = {
[Experiment.HomepageExperiment]: () =>
Cookies.get(Experiment.HomepageExperiment) === ExperimentVariant.B,
};

/**
* Check if an experiment is enabled
*/
export function isExperimentEnabled(experiment: Experiment): boolean {
const experimentFunction = Experiments[experiment];
if (!experimentFunction) {
console.warn(`Experiment "${experiment}" is not defined.`);
return false;
}

return experimentFunction();
}

/**
* Check if an experiment is enabled on the server.
*
* @param experimentVariant The experiment variant from the request (for server-side)
* @returns True if the experiment is enabled, false otherwise.
*/
export function isExperimentEnabledServer(experimentVariant?: ExperimentVariant | null): boolean {
return experimentVariant === ExperimentVariant.B;
}
21 changes: 20 additions & 1 deletion utils/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation';
import { Work } from '@/types/work';
import { generateSlug } from '@/utils/url';
import { ExperimentVariant, isExperimentEnabledServer } from '@/utils/experiment';

/**
* Opens an author profile using the appropriate routing mechanism
Expand Down Expand Up @@ -109,7 +110,25 @@ export function handleMissingSlugRedirect(work: Work, id: string, currentPath: s
* @param isUserLoggedIn Whether the user is logged in
* @param searchParams Optional search parameters to preserve in the redirect
*/
export function handleTrendingRedirect(isUserLoggedIn: boolean, searchParams?: URLSearchParams) {
export function handleTrendingRedirect(
isUserLoggedIn: boolean,
searchParams?: URLSearchParams,
homepageExperimentVariant?: ExperimentVariant | null
) {
// Redirect if user is logged in OR if homepage experiment is enabled
const isHPExperimentEnabled = isExperimentEnabledServer(homepageExperimentVariant);

if (!isUserLoggedIn && isHPExperimentEnabled) {
let redirectUrl = '/popular';

// Preserve search parameters if provided
if (searchParams && searchParams.toString()) {
redirectUrl += `?${searchParams.toString()}`;
}

redirect(redirectUrl);
}

if (isUserLoggedIn) {
let redirectUrl = '/for-you';

Expand Down