Skip to content

Commit 629e365

Browse files
rebelchrisgithub-actions[bot]claude
authored
feat: default light theme for recruiter-entry users (#3442)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Chris Bongers <rebelchris@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4371bc8 commit 629e365

2 files changed

Lines changed: 192 additions & 6 deletions

File tree

__tests__/boot.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,110 @@ describe('anonymous boot', () => {
377377
});
378378
});
379379

380+
describe('recruiter default theme', () => {
381+
it('should return light theme for anonymous user with referrer=recruiter', async () => {
382+
const res = await request(app.server)
383+
.get(`${BASE_PATH}?referrer=recruiter`)
384+
.set('User-Agent', TEST_UA)
385+
.expect(200);
386+
expect(res.body.settings.theme).toEqual('bright');
387+
});
388+
389+
it('should return dark theme for anonymous user without referrer', async () => {
390+
const res = await request(app.server)
391+
.get(BASE_PATH)
392+
.set('User-Agent', TEST_UA)
393+
.expect(200);
394+
expect(res.body.settings.theme).toEqual('darcula');
395+
});
396+
397+
it('should return dark theme for unknown referrer values', async () => {
398+
const res = await request(app.server)
399+
.get(`${BASE_PATH}?referrer=unknown`)
400+
.set('User-Agent', TEST_UA)
401+
.expect(200);
402+
expect(res.body.settings.theme).toEqual('darcula');
403+
});
404+
405+
it('should persist theme in Redis and return it on subsequent visits', async () => {
406+
// First visit with recruiter referrer
407+
const first = await request(app.server)
408+
.get(`${BASE_PATH}?referrer=recruiter`)
409+
.set('User-Agent', TEST_UA)
410+
.expect(200);
411+
expect(first.body.settings.theme).toEqual('bright');
412+
413+
// Second visit without referrer should still return light theme
414+
const second = await request(app.server)
415+
.get(BASE_PATH)
416+
.set('User-Agent', TEST_UA)
417+
.set('Cookie', first.headers['set-cookie'])
418+
.expect(200);
419+
expect(second.body.settings.theme).toEqual('bright');
420+
});
421+
422+
it('should not override stored theme with new referrer', async () => {
423+
// First visit without referrer (dark theme)
424+
const first = await request(app.server)
425+
.get(BASE_PATH)
426+
.set('User-Agent', TEST_UA)
427+
.expect(200);
428+
expect(first.body.settings.theme).toEqual('darcula');
429+
430+
// Second visit with recruiter referrer should still return dark theme
431+
const second = await request(app.server)
432+
.get(`${BASE_PATH}?referrer=recruiter`)
433+
.set('User-Agent', TEST_UA)
434+
.set('Cookie', first.headers['set-cookie'])
435+
.expect(200);
436+
expect(second.body.settings.theme).toEqual('darcula');
437+
});
438+
439+
it('should store theme in Redis with correct key pattern', async () => {
440+
const res = await request(app.server)
441+
.get(`${BASE_PATH}?referrer=recruiter`)
442+
.set('User-Agent', TEST_UA)
443+
.expect(200);
444+
445+
const trackingId = res.body.user.id;
446+
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', trackingId);
447+
const storedTheme = await getRedisObject(themeKey);
448+
expect(storedTheme).toEqual('bright');
449+
});
450+
451+
it('should persist theme after login', async () => {
452+
const anon = await request(app.server)
453+
.get(`${BASE_PATH}?referrer=recruiter`)
454+
.set('User-Agent', TEST_UA)
455+
.expect(200);
456+
expect(anon.body.settings.theme).toEqual('bright');
457+
458+
// Simulate theme migration on login
459+
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', '1');
460+
await setRedisObject(themeKey, 'bright');
461+
462+
mockLoggedIn();
463+
const loggedIn = await request(app.server)
464+
.get(BASE_PATH)
465+
.set('Cookie', 'ory_kratos_session=value;')
466+
.expect(200);
467+
expect(loggedIn.body.settings.theme).toEqual('bright');
468+
});
469+
470+
it('should prefer DB settings over Redis theme', async () => {
471+
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', '1');
472+
await setRedisObject(themeKey, 'bright');
473+
await con.getRepository(Settings).save({ userId: '1', theme: 'darcula' });
474+
475+
mockLoggedIn();
476+
const res = await request(app.server)
477+
.get(BASE_PATH)
478+
.set('Cookie', 'ory_kratos_session=value;')
479+
.expect(200);
480+
expect(res.body.settings.theme).toEqual('darcula');
481+
});
482+
});
483+
380484
describe('logged in boot', () => {
381485
it('should boot data when no access token cookie but whoami succeeds', async () => {
382486
mockLoggedIn();

src/routes/boot.ts

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ const loggedInBoot = async ({
662662
],
663663
balance,
664664
clickbaitTries,
665+
anonymousTheme,
665666
] = await Promise.all([
666667
visitSection(req, res),
667668
getRoles(userId),
@@ -687,6 +688,7 @@ const loggedInBoot = async ({
687688
}),
688689
getBalanceBoot({ userId }),
689690
getClickbaitTries({ userId }),
691+
getAnonymousTheme(userId),
690692
]);
691693

692694
const profileCompletion = calculateProfileCompletion(user, experienceFlags);
@@ -695,6 +697,12 @@ const loggedInBoot = async ({
695697
return handleNonExistentUser(con, req, res, middleware);
696698
}
697699

700+
// Apply anonymous theme (e.g. recruiter light mode) if user has no saved settings
701+
const finalSettings =
702+
!settings.updatedAt && anonymousTheme
703+
? { ...settings, theme: anonymousTheme }
704+
: settings;
705+
698706
const hasLocationSet = !!user.flags?.location?.lastStored;
699707
const isTeamMember = exp?.a?.team === 1;
700708
const isPlus = isPlusMember(user.subscriptionFlags?.cycle);
@@ -781,7 +789,7 @@ const loggedInBoot = async ({
781789
subDays(new Date(), FEED_SURVEY_INTERVAL) >
782790
alerts.lastFeedSettingsFeedback,
783791
},
784-
settings: excludeProperties(settings, [
792+
settings: excludeProperties(finalSettings, [
785793
'userId',
786794
'updatedAt',
787795
'bookmarkSlug',
@@ -809,6 +817,46 @@ const getAnonymousFirstVisit = async (trackingId?: string) => {
809817
return finalValue;
810818
};
811819

820+
const ANONYMOUS_THEME_TTL = ONE_DAY_IN_SECONDS * 30; // 30 days, same as firstVisit
821+
822+
const getThemeRedisKey = (id: string): string =>
823+
generateStorageKey(StorageTopic.Boot, 'theme', id);
824+
825+
/**
826+
* Get stored theme preference from Redis for anonymous or authenticated users
827+
*/
828+
export const getAnonymousTheme = async (
829+
id?: string,
830+
): Promise<string | null> => {
831+
if (!id) return null;
832+
return getRedisObject(getThemeRedisKey(id));
833+
};
834+
835+
/**
836+
* Store theme preference in Redis for anonymous or authenticated users
837+
*/
838+
export const setAnonymousTheme = async (
839+
id: string,
840+
theme: string,
841+
): Promise<void> => {
842+
await setRedisObjectWithExpiry(
843+
getThemeRedisKey(id),
844+
theme,
845+
ANONYMOUS_THEME_TTL,
846+
);
847+
};
848+
849+
/**
850+
* Determine default theme based on referrer
851+
* Recruiter-facing pages default to light mode
852+
*/
853+
const getDefaultThemeForReferrer = (referrer?: string): string => {
854+
if (referrer === 'recruiter') {
855+
return 'bright'; // light mode
856+
}
857+
return 'darcula'; // dark mode
858+
};
859+
812860
// We released the firstVisit at July 10, 2023.
813861
// There should have been enough buffer time since we are releasing on July 13, 2023.
814862
export const onboardingV2Requirement = new Date(2023, 6, 13);
@@ -820,16 +868,24 @@ const anonymousBoot = async (
820868
middleware?: BootMiddleware,
821869
shouldVerify = false,
822870
email?: string,
871+
referrer?: string,
823872
): Promise<AnonymousBoot> => {
824873
const geo = geoSection(req);
825874

826-
const [visit, extra, firstVisit, exp] = await Promise.all([
875+
const [visit, extra, firstVisit, exp, existingTheme] = await Promise.all([
827876
visitSection(req, res),
828877
middleware ? middleware(con, req, res) : {},
829878
getAnonymousFirstVisit(req.trackingId),
830879
getExperimentation({ userId: req.trackingId, con, ...geo }),
880+
getAnonymousTheme(req.trackingId),
831881
]);
832882

883+
// Determine theme: use existing preference or referrer-based default
884+
const theme = existingTheme ?? getDefaultThemeForReferrer(referrer);
885+
if (!existingTheme && req.trackingId) {
886+
await setAnonymousTheme(req.trackingId, theme);
887+
}
888+
833889
return {
834890
user: {
835891
firstVisit,
@@ -844,7 +900,10 @@ const anonymousBoot = async (
844900
changelog: false,
845901
shouldShowFeedFeedback: false,
846902
},
847-
settings: SETTINGS_DEFAULT,
903+
settings: {
904+
...SETTINGS_DEFAULT,
905+
...(theme && { theme }),
906+
},
848907
notifications: { unreadNotificationsCount: 0 },
849908
squads: [],
850909
exp,
@@ -859,6 +918,9 @@ export const getBootData = async (
859918
res: FastifyReply,
860919
middleware?: BootMiddleware,
861920
): Promise<AnonymousBoot | LoggedInBoot> => {
921+
// Extract referrer from query params (e.g., ?referrer=recruiter)
922+
const referrer = (req.query as { referrer?: string })?.referrer;
923+
862924
if (
863925
req.userId &&
864926
req.accessToken?.expiresIn &&
@@ -880,9 +942,25 @@ export const getBootData = async (
880942
setRawCookie(res, whoami.cookie);
881943
}
882944
if (whoami.verified === false) {
883-
return anonymousBoot(con, req, res, middleware, true, whoami?.email);
945+
return anonymousBoot(
946+
con,
947+
req,
948+
res,
949+
middleware,
950+
true,
951+
whoami?.email,
952+
referrer,
953+
);
884954
}
885955
if (req.userId !== whoami.userId) {
956+
// Migrate theme from anonymous trackingId to new userId before overwriting
957+
const oldTrackingId = req.trackingId;
958+
if (oldTrackingId && oldTrackingId !== whoami.userId) {
959+
const anonymousTheme = await getAnonymousTheme(oldTrackingId);
960+
if (anonymousTheme) {
961+
await setAnonymousTheme(whoami.userId, anonymousTheme);
962+
}
963+
}
886964
req.userId = whoami.userId;
887965
req.trackingId = req.userId;
888966
setTrackingId(req, res, req.trackingId);
@@ -897,9 +975,9 @@ export const getBootData = async (
897975
});
898976
} else if (req.cookies[cookies.kratos.key]) {
899977
await clearAuthentication(req, res, 'invalid cookie');
900-
return anonymousBoot(con, req, res, middleware);
978+
return anonymousBoot(con, req, res, middleware, false, undefined, referrer);
901979
}
902-
return anonymousBoot(con, req, res, middleware);
980+
return anonymousBoot(con, req, res, middleware, false, undefined, referrer);
903981
};
904982

905983
const COMPANION_QUERY = parse(`query Post($url: String) {
@@ -1149,6 +1227,7 @@ const funnelBoots = {
11491227
const funnelHandler: RouteHandler = async (req, res) => {
11501228
const con = await createOrGetConnection();
11511229
const { id = 'funnel' } = req.params as { id: keyof typeof funnelBoots };
1230+
const referrer = (req.query as { referrer?: string })?.referrer;
11521231

11531232
if (id in funnelBoots) {
11541233
const funnel = funnelBoots[id];
@@ -1157,6 +1236,9 @@ const funnelHandler: RouteHandler = async (req, res) => {
11571236
req,
11581237
res,
11591238
generateFunnelBootMiddle(funnel),
1239+
false,
1240+
undefined,
1241+
referrer,
11601242
)) as FunnelBoot;
11611243
return res.send(data);
11621244
}

0 commit comments

Comments
 (0)