Skip to content

Commit b4cc212

Browse files
authored
Merge pull request #109 from requestly/vsanse/growthbook-banner-check
feat(banner): GrowthBook-driven legacy auth migration banner (RQ-5181)
2 parents 77675ad + 6d29552 commit b4cc212

10 files changed

Lines changed: 150 additions & 4 deletions

File tree

app/src/componentsV2/AppNotificationBanner/banner.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export enum BANNER_ACTIONS {
2121
REDIRECT_TO_CHROME_STORE_REVIEWS = "redirect_to_chrome_store_reviews",
2222
REDIRECT_TO_LINKEDIN_FORM = "redirect_to_linkedin_form",
2323
REDIRECT_TO_NOTION_PAGE = "redirect_to_notion_page",
24+
MERGE_BSTACK_ACCOUNT = "merge_bstack_account",
2425
}
2526

2627
export enum BANNER_ID {
@@ -32,6 +33,7 @@ export enum BANNER_ID {
3233
BILLING_TEAM_PLAN_REMINDER = "billing_team_plan_reminder",
3334
CHROME_STORE_REVIEWS = "chrome_store_reviews",
3435
SHARE_ON_LINKEDIN = "share_on_linkedin",
36+
LOGIN_MIGRATION = "login_migration",
3537
}
3638

3739
export interface Banner {

app/src/componentsV2/AppNotificationBanner/hooks/useBannerAction.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ import { httpsCallable, getFunctions } from "firebase/functions";
99
import { toast } from "utils/Toast";
1010
import { trackCheckoutFailedEvent, trackCheckoutInitiated } from "modules/analytics/events/misc/business/checkout";
1111
import STORAGE from "config/constants/sub/storage";
12+
import { CONSTANTS as GLOBAL_CONSTANTS } from "@requestly/requestly-core";
13+
import { getAppMode } from "store/selectors";
14+
import { setRedirectMetadata } from "features/onboarding/utils";
15+
import { SOURCE } from "modules/analytics/events/common/constants";
1216

1317
export const useBannerAction = (
1418
actions: BANNER_ACTIONS[],
1519
setIsRequestAccessModalOpen?: (value: boolean) => void
1620
): BannerActionConfig[] => {
1721
const dispatch = useDispatch();
1822
const user = useSelector(getUserAuthDetails);
23+
const appMode = useSelector(getAppMode);
1924
const firebaseFunction = getFunctions();
2025

2126
const actionMap: Record<BANNER_ACTIONS, BannerActionConfig> = useMemo(
@@ -69,6 +74,30 @@ export const useBannerAction = (
6974
redirectToUrl(LINKS.NOTION_PAGE_FOR_PROMOTION, true);
7075
},
7176
},
77+
[BANNER_ACTIONS.MERGE_BSTACK_ACCOUNT]: {
78+
label: "Sign in",
79+
type: "primary",
80+
onClick: () => {
81+
// Brings a web user back to the page they started from. LoginHandler reads and clears
82+
// this after the token exchange. Harmless on desktop, where the OAuth flow runs in a
83+
// different browser and nothing reads it back.
84+
setRedirectMetadata({
85+
source: SOURCE.AUTH_MIGRATION_BANNER,
86+
redirectURL: window.location.href,
87+
});
88+
89+
if (appMode === GLOBAL_CONSTANTS.APP_MODES.DESKTOP) {
90+
// redirectToUrl is window.open(url, "_self"), which would navigate the whole Electron
91+
// window away from the app. Hand the URL to the system browser instead.
92+
window.RQ?.DESKTOP?.SERVICES?.IPC?.invokeEventInBG("open-external-link", {
93+
link: LINKS.OAUTH_REDIRECT_URL,
94+
});
95+
return;
96+
}
97+
98+
redirectToUrl(LINKS.OAUTH_REDIRECT_URL);
99+
},
100+
},
72101
[BANNER_ACTIONS.REDIRECT_TO_LINKEDIN_FORM]: {
73102
label: "Share Now",
74103
type: "primary",
@@ -120,7 +149,7 @@ export const useBannerAction = (
120149
},
121150
},
122151
}),
123-
[dispatch, firebaseFunction, user?.details?.planDetails, setIsRequestAccessModalOpen]
152+
[dispatch, firebaseFunction, user?.details?.planDetails, setIsRequestAccessModalOpen, appMode]
124153
);
125154

126155
return actions.map((action) => actionMap[action]).filter(Boolean);

app/src/componentsV2/AppNotificationBanner/hooks/useBannerVisibility.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { PRICING } from "features/pricing/constants/pricing";
66
import { getUserAuthDetails } from "store/slices/global/user/selectors";
77
import { getAvailableBillingTeams } from "store/features/billing/selectors";
88
import { useFeatureValue } from "@growthbook/growthbook-react";
9-
import { getAppNotificationBannerDismissTs } from "store/selectors";
9+
import { getAppNotificationBannerDismissTs, getUserBrowserstackId } from "store/selectors";
1010

1111
export const useBannerVisibility = (bannerId: string): boolean => {
1212
const user = useSelector(getUserAuthDetails);
1313
const billingTeams = useSelector(getAvailableBillingTeams);
1414
const lastDismissTs = useSelector(getAppNotificationBannerDismissTs);
15+
const browserstackId = useSelector(getUserBrowserstackId);
1516
const allBanners = useFeatureValue("app_banner", []);
1617
const newBanners = useMemo(() => allBanners.filter((banner: any) => banner.createdTs > (lastDismissTs || 0)), [
1718
allBanners,
@@ -63,8 +64,14 @@ export const useBannerVisibility = (bannerId: string): boolean => {
6364
return isTeamOwner;
6465
}
6566

67+
case BANNER_ID.LOGIN_MIGRATION:
68+
// `undefined` means the user doc has not been read yet, `null` means it was read and the
69+
// user has no BrowserStack account. Only the second should show the banner, otherwise an
70+
// already-merged user sees it flash at every cold start.
71+
return Boolean(user?.loggedIn) && browserstackId === null;
72+
6673
default:
6774
return true;
6875
}
69-
}, [bannerId, user, billingTeams, newBanners]);
76+
}, [bannerId, user, billingTeams, newBanners, browserstackId]);
7077
};

app/src/componentsV2/AppNotificationBanner/hooks/useRenderBannerText.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getCompanyNameFromEmail, getPrettyPlanName } from "utils/FormattingHelp
44
import { isCompanyEmail } from "utils/mailCheckerUtils";
55
import { Banner, BANNER_ID } from "../banner.types";
66
import { getUserAuthDetails } from "store/slices/global/user/selectors";
7+
import { interpolateBannerText } from "../utils/interpolateBannerText";
78

89
export const useRenderBannerText = (banner: Banner): string => {
910
const user = useSelector(getUserAuthDetails);
@@ -16,6 +17,8 @@ export const useRenderBannerText = (banner: Banner): string => {
1617
user?.details?.planDetails?.planName,
1718
]);
1819

20+
const email = user?.details?.profile?.email || "";
21+
1922
switch (banner.id) {
2023
case BANNER_ID.COMMERCIAL_LICENSE:
2124
case BANNER_ID.REQUEST_TEAM_ACCESS:
@@ -30,6 +33,6 @@ export const useRenderBannerText = (banner: Banner): string => {
3033
return `You're on the ${planName} Monthly plan. Switch to the annual plan and save over 40% on your annual spends with our New Year Deal.`;
3134

3235
default:
33-
return banner.text;
36+
return interpolateBannerText(banner.text, { email });
3437
}
3538
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { interpolateBannerText } from "./interpolateBannerText";
4+
5+
describe("interpolateBannerText", () => {
6+
it("substitutes a single placeholder", () => {
7+
expect(interpolateBannerText("Link {{email}} now", { email: "jane@acme.com" })).toBe("Link jane@acme.com now");
8+
});
9+
10+
it("substitutes the same placeholder more than once", () => {
11+
expect(interpolateBannerText("{{email}} and {{email}}", { email: "jane@acme.com" })).toBe(
12+
"jane@acme.com and jane@acme.com"
13+
);
14+
});
15+
16+
it("tolerates whitespace inside the braces", () => {
17+
expect(interpolateBannerText("Link {{ email }} now", { email: "jane@acme.com" })).toBe("Link jane@acme.com now");
18+
});
19+
20+
it("leaves an unknown placeholder as literal text", () => {
21+
expect(interpolateBannerText("Hello {{name}}", { email: "jane@acme.com" })).toBe("Hello {{name}}");
22+
});
23+
24+
it("leaves the placeholder alone when the value is an empty string", () => {
25+
expect(interpolateBannerText("Link {{email}} now", { email: "" })).toBe("Link {{email}} now");
26+
});
27+
28+
it("treats replacement patterns in the value as literal characters", () => {
29+
expect(interpolateBannerText("Link {{email}}", { email: "a$&b@acme.com" })).toBe("Link a$&b@acme.com");
30+
});
31+
32+
it("returns the text unchanged when it holds no placeholder", () => {
33+
expect(interpolateBannerText("Nothing to replace", { email: "jane@acme.com" })).toBe("Nothing to replace");
34+
});
35+
36+
it("returns the input unchanged when it is empty", () => {
37+
expect(interpolateBannerText("", { email: "jane@acme.com" })).toBe("");
38+
});
39+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2+
3+
/**
4+
* Substitutes {{key}} placeholders in GrowthBook-authored banner copy.
5+
* An unknown or empty value leaves the placeholder in place on purpose, so a typo in the
6+
* GrowthBook entry reads as an obvious "{{foo}}" rather than as "undefined".
7+
*/
8+
export const interpolateBannerText = (text: string, values: Record<string, string>): string => {
9+
if (!text) {
10+
return text;
11+
}
12+
13+
// The replacer must stay a function — it keeps "$&" and friends inside the value literal.
14+
return text.replace(PLACEHOLDER_PATTERN, (placeholder, key) => values[key] || placeholder);
15+
};

app/src/hooks/DbListenerInit/DBListeners.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getAppMode, getAuthInitialization } from "../../store/selectors";
44
import { getUserAuthDetails } from "store/slices/global/user/selectors";
55
import syncingNodeListener from "./syncingNodeListener";
66
import userNodeListener from "./userNodeListener";
7+
import userDocListener from "./userDocListener";
78
import { globalActions } from "store/slices/global/slice";
89
import { isArray } from "lodash";
910
import { useHasChanged } from "hooks/useHasChanged";
@@ -21,15 +22,18 @@ const DBListeners = () => {
2122
const hasAuthInitialized = useSelector(getAuthInitialization);
2223

2324
let unsubscribeUserNodeRef = useRef(null);
25+
let unsubscribeUserDocRef = useRef(null);
2426
window.unsubscribeSyncingNodeRef = useRef(null);
2527

2628
const hasAuthStateChanged = useHasChanged(user?.loggedIn);
2729

2830
// Listens to /users/{id} changes
2931
useEffect(() => {
3032
if (unsubscribeUserNodeRef.current) unsubscribeUserNodeRef.current(); // Unsubscribe existing user node listener before creating a new one
33+
if (unsubscribeUserDocRef.current) unsubscribeUserDocRef.current();
3134
if (user?.loggedIn) {
3235
unsubscribeUserNodeRef.current = userNodeListener(dispatch, user?.details?.profile.uid, appMode);
36+
unsubscribeUserDocRef.current = userDocListener(user?.details?.profile.uid);
3337
/* CAN BE MOVED TO SEPARATE USE EFFECT AND SHOULD HAVE AN UNSUBSCRIBER TOO, will be useful when actually implementing premium */
3438
userSubscriptionDocListener(dispatch, user?.details?.profile.uid);
3539
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { doc, getFirestore, onSnapshot } from "firebase/firestore";
2+
import firebaseApp from "../../firebase";
3+
import APP_CONSTANTS from "config/constants";
4+
import { submitAttrUtil } from "utils/AnalyticsUtils";
5+
import Logger from "lib/logger";
6+
7+
/**
8+
* Keeps the GrowthBook attribute `browserstack_id` in step with the Firestore user doc.
9+
* The BrowserStack merge writes `browserstackId` from a different browser when the app runs on
10+
* desktop, and AuthHandler only reads it once from onAuthStateChanged — so without this
11+
* subscription the desktop app would not notice the merge until the next restart.
12+
*
13+
* @returns the unsubscribe function, or null when no listener was attached.
14+
*/
15+
const userDocListener = (uid) => {
16+
if (!uid) {
17+
return null;
18+
}
19+
20+
try {
21+
const db = getFirestore(firebaseApp);
22+
const userDocRef = doc(db, "users", uid);
23+
24+
return onSnapshot(
25+
userDocRef,
26+
(docSnapshot) => {
27+
const userData = docSnapshot.exists() ? docSnapshot.data() : null;
28+
submitAttrUtil(APP_CONSTANTS.GA_EVENTS.ATTR.BROWSERSTACK_ID, userData?.browserstackId ?? null);
29+
},
30+
(err) => {
31+
Logger.log(`[userDocListener] Encountered error: ${err}`);
32+
}
33+
);
34+
} catch (err) {
35+
Logger.log(`[userDocListener] Failed to attach listener: ${err}`);
36+
return null;
37+
}
38+
};
39+
40+
export default userDocListener;

app/src/modules/analytics/events/common/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export const SOURCE = {
135135
SHARED_LIST_SCREEN: "shared_list_screen",
136136
EXTENSION_ONBOARDING: "extension_onboarding",
137137
DESKTOP_ONBOARDING: "desktop_onboarding",
138+
AUTH_MIGRATION_BANNER: "auth_migration_banner",
138139
PRICING_TABLE: "pricing_table",
139140
TEAM_WORKSPACE_BAD_INVITE_SCREEN: "team_workspace_bad_invite_screen",
140141
WORKSPACE_DROPDOWN: "workspace_dropdown",

app/src/store/selectors.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ export const getUserAttributes = (state) => {
215215
return getGlobalState(state)["userAttributes"];
216216
};
217217

218+
// Returns a primitive on purpose — consuming the whole `userAttributes` object in a component
219+
// re-renders on every attribute write, as the getUserRulesCount note below records.
220+
export const getUserBrowserstackId = (state) => {
221+
return getUserAttributes(state)?.browserstack_id;
222+
};
223+
218224
// Had to make a separate selector, since consuming
219225
// "userAttributes" directly in <RulesListContainer/> component goes into infinite re-renders
220226
// TODO: fix above

0 commit comments

Comments
 (0)