From c62a6616db9f055cb7c1713f750b6e8d6d77dbcc Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:37:25 +0700 Subject: [PATCH] feat(frontend): integrate with Reo.dev (#11251) --- docs/reo-init.js | 14 +++ frontend/global.d.ts | 14 +++ frontend/src/hooks/use-reo-tracking.ts | 129 +++++++++++++++++++++++++ frontend/src/routes/root-layout.tsx | 4 + frontend/src/utils/reo.ts | 104 ++++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 docs/reo-init.js create mode 100644 frontend/src/hooks/use-reo-tracking.ts create mode 100644 frontend/src/utils/reo.ts diff --git a/docs/reo-init.js b/docs/reo-init.js new file mode 100644 index 0000000000..9559ed0d55 --- /dev/null +++ b/docs/reo-init.js @@ -0,0 +1,14 @@ +// Reo.dev tracking initialization +(function() { + var e, t, n; + e = "6bac7145b4ee6ec"; + t = function() { + Reo.init({clientID: "6bac7145b4ee6ec"}); + }; + n = document.createElement("script"); + n.src = "https://static.reo.dev/" + e + "/reo.js"; + n.defer = true; + n.onload = t; + document.head.appendChild(n); +})(); + diff --git a/frontend/global.d.ts b/frontend/global.d.ts index f3ad7483ea..7317d3332d 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -1,4 +1,18 @@ interface Window { __APP_MODE__?: "saas" | "oss"; __GITHUB_CLIENT_ID__?: string | null; + Reo?: { + init: (config: { clientID: string }) => void; + identify: (identity: { + username: string; + type: "github" |"email"; + other_identities?: Array<{ + username: string; + type: "github" | "email"; + }>; + firstname?: string; + lastname?: string; + company?: string; + }) => void; + }; } diff --git a/frontend/src/hooks/use-reo-tracking.ts b/frontend/src/hooks/use-reo-tracking.ts new file mode 100644 index 0000000000..08d8851a2a --- /dev/null +++ b/frontend/src/hooks/use-reo-tracking.ts @@ -0,0 +1,129 @@ +import React from "react"; +import { useConfig } from "./query/use-config"; +import { useGitUser } from "./query/use-git-user"; +import { getLoginMethod, LoginMethod } from "#/utils/local-storage"; +import reoService, { ReoIdentity } from "#/utils/reo"; + +/** + * Maps login method to Reo identity type + */ +const mapLoginMethodToReoType = (method: LoginMethod): ReoIdentity["type"] => { + // Reo is not supporting gitlab and bitbucket. + switch (method) { + case LoginMethod.GITHUB: + return "github"; + case LoginMethod.ENTERPRISE_SSO: + return "email"; + default: + return "email"; + } +}; + +/** + * Creates email identity object if email is available + */ +const buildEmailIdentity = ( + email?: string | null, +): ReoIdentity["other_identities"] => { + if (!email) { + return undefined; + } + + return [ + { + username: email, + type: "email", + }, + ]; +}; + +/** + * Parses full name into firstname and lastname + * Handles cases where name might be empty or only have one part + */ +const parseNameFields = ( + fullName?: string | null, +): { firstname?: string; lastname?: string } => { + if (!fullName) { + return {}; + } + + const [firstname, ...rest] = fullName.split(" "); + if (!firstname) { + return {}; + } + + return { + firstname, + lastname: rest.length > 0 ? rest.join(" ") : undefined, + }; +}; + +/** + * Builds complete Reo identity from user data and login method + */ +const buildReoIdentity = ( + user: { + login: string; + email?: string | null; + name?: string | null; + company?: string | null; + }, + loginMethod: LoginMethod, +): ReoIdentity => { + const { firstname, lastname } = parseNameFields(user.name); + + return { + username: user.login, + type: mapLoginMethodToReoType(loginMethod), + other_identities: buildEmailIdentity(user.email), + firstname, + lastname, + company: user.company || undefined, + }; +}; + +/** + * Hook to handle Reo.dev tracking integration + * Only active in SaaS mode + */ +export const useReoTracking = () => { + const { data: config } = useConfig(); + const { data: user } = useGitUser(); + const [hasIdentified, setHasIdentified] = React.useState(false); + + // Initialize Reo.dev when in SaaS mode + React.useEffect(() => { + const initReo = async () => { + if (config?.APP_MODE === "saas" && !reoService.isInitialized()) { + await reoService.init(); + } + }; + + initReo(); + }, [config?.APP_MODE]); + + // Identify user when user data is available and we're in SaaS mode + React.useEffect(() => { + if ( + config?.APP_MODE !== "saas" || + !user || + hasIdentified || + !reoService.isInitialized() + ) { + return; + } + + const loginMethod = getLoginMethod(); + if (!loginMethod) { + return; + } + + // Build identity payload from user data + const identity = buildReoIdentity(user, loginMethod); + + // Identify user in Reo + reoService.identify(identity); + setHasIdentified(true); + }, [config?.APP_MODE, user, hasIdentified]); +}; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 9287a5c0bf..05ad5ae142 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -24,6 +24,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page"; import { useAutoLogin } from "#/hooks/use-auto-login"; import { useAuthCallback } from "#/hooks/use-auth-callback"; +import { useReoTracking } from "#/hooks/use-reo-tracking"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; @@ -96,6 +97,9 @@ export default function MainApp() { // Handle authentication callback and set login method after successful authentication useAuthCallback(); + // Initialize Reo.dev tracking in SaaS mode + useReoTracking(); + React.useEffect(() => { // Don't change language when on TOS page if (!isOnTosPage && settings?.LANGUAGE) { diff --git a/frontend/src/utils/reo.ts b/frontend/src/utils/reo.ts new file mode 100644 index 0000000000..9f76c98d31 --- /dev/null +++ b/frontend/src/utils/reo.ts @@ -0,0 +1,104 @@ +/** + * Reo.dev tracking service for SaaS mode + * Tracks developer activity and engagement in the product + * Using CDN approach for better TypeScript compatibility + */ + +export interface ReoIdentity { + username: string; + type: "github" | "email"; + other_identities?: Array<{ + username: string; + type: "github" | "email"; + }>; + firstname?: string; + lastname?: string; + company?: string; +} + +const REO_CLIENT_ID = "6bac7145b4ee6ec"; + +class ReoService { + private initialized = false; + + private scriptLoaded = false; + + /** + * Load and initialize the Reo.dev tracking script from CDN + */ + async init(): Promise { + if (this.initialized) { + return; + } + + try { + // Load the Reo script dynamically from CDN + await this.loadScript(); + + // Initialize Reo with client ID + if (window.Reo) { + window.Reo.init({ clientID: REO_CLIENT_ID }); + this.initialized = true; + } + } catch (error) { + console.error("Failed to initialize Reo.dev tracking:", error); + } + } + + /** + * Load the Reo.dev script from CDN + */ + private loadScript(): Promise { + return new Promise((resolve, reject) => { + if (this.scriptLoaded) { + resolve(); + return; + } + + const script = document.createElement("script"); + script.src = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`; + script.defer = true; + + script.onload = () => { + this.scriptLoaded = true; + resolve(); + }; + + script.onerror = () => { + reject(new Error("Failed to load Reo.dev script")); + }; + + document.head.appendChild(script); + }); + } + + /** + * Identify a user in Reo.dev tracking + * Should be called after successful login + */ + identify(identity: ReoIdentity): void { + if (!this.initialized) { + console.warn("Reo.dev not initialized. Call init() first."); + return; + } + + try { + if (window.Reo) { + window.Reo.identify(identity); + } + } catch (error) { + console.error("Failed to identify user in Reo.dev:", error); + } + } + + /** + * Check if Reo.dev is initialized + */ + isInitialized(): boolean { + return this.initialized; + } +} + +const reoService = new ReoService(); + +export default reoService;