diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 7e5b0855e3ad..0fe1295cac73 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -554,6 +554,7 @@ export type SubscriptionMeta = { export type Account = { first_name: string last_name: string + date_joined: string sign_up_type: SignupType id: number email: string @@ -800,7 +801,7 @@ export type Webhook = { updated_at: string } -export type AccountModel = User & { +export type AccountModel = Account & { organisations: Organisation[] } diff --git a/frontend/common/utils/getUserDisplayName.tsx b/frontend/common/utils/getUserDisplayName.tsx index 0fc8c12b76d5..ec49935670e6 100644 --- a/frontend/common/utils/getUserDisplayName.tsx +++ b/frontend/common/utils/getUserDisplayName.tsx @@ -1,6 +1,9 @@ -import { User } from 'common/types/responses' +import { AccountModel, User } from 'common/types/responses' -export default function (user: User | undefined, defaultName = 'Unknown') { +export default function ( + user: AccountModel | undefined, + defaultName = 'Unknown', +) { if (!user) { return defaultName } diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 540d77450dbf..2ea841e7a7a5 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -15,10 +15,30 @@ type Crisp = { // The push method accepts a CrispCommand array. push: (command: CrispCommand) => void } - +declare namespace UniversalAnalytics { + interface PageviewFieldsObject { + hitType: 'pageview' | 'event' + location?: string + page?: string + title?: string + [key: string]: any + } +} export declare const openModal: (name?: string) => Promise declare global { + function ga( + command: 'send', + fields: UniversalAnalytics.PageviewFieldsObject, + ): void const $crisp: Crisp + const delighted: { + survey: (opts: { + createdAt: string + email: string + name: string + properties: Record + }) => void + } const openModal: ( title: ReactNode, body?: ReactNode, @@ -52,5 +72,10 @@ declare global { const Tooltip: FC interface Window { $crisp: Crisp + engagement: { + init(apiKey: string, options?: InitOptions): void + plugin(): unknown + boot(options: BootOptions): Promise + } } } diff --git a/frontend/web/project/api.js b/frontend/web/project/api.js deleted file mode 100644 index 5ea49ccb53d7..000000000000 --- a/frontend/web/project/api.js +++ /dev/null @@ -1,357 +0,0 @@ -import * as amplitude from '@amplitude/analytics-browser' -import data from 'common/data/base/_data' -import isFreeEmailDomain from 'common/utils/isFreeEmailDomain' - -import { loadReoScript } from 'reodotdev' -import { groupBy } from 'lodash' -import getUserDisplayName from 'common/utils/getUserDisplayName' - -global.API = { - ajaxHandler(store, res) { - switch (res.status) { - case 404: - // ErrorModal(null, 'API Not found: '); - break - case 503: - // ErrorModal(null, error); - break - default: - // ErrorModal(null, error); - } - - // Catch coding errors that end up here - if (typeof res === 'string') { - store.error = new Error(res) - store.goneABitWest() - return - } - if (res instanceof Error) { - console.error(res) - store.error = res - store.goneABitWest() - return - } else if (res.data) { - store.error = res.data - store.goneABitWest() - return - } else if (typeof res.text !== 'function') { - store.error = res - store.goneABitWest() - return - } - - res - .text() - .then((error) => { - if (store) { - let err = error - try { - err = JSON.parse(error) - } catch (e) {} - store.error = err - store.goneABitWest() - } - }) - .catch(() => { - if (store) { - store.goneABitWest() - } - }) - }, - alias(id, user = {}) { - if (Project.excludeAnalytics?.includes(id)) return - Utils.setupCrisp() - if (Project.reo) { - const reoPromise = loadReoScript({ clientID: Project.reo }) - reoPromise.then((Reo) => { - Reo.init({ clientID: Project.reo }) - let authType = 'userID' - switch (user.auth_type) { - case 'EMAIL': - authType = 'email' - break - case 'GITHUB': - authType = 'github' - break - case 'GOOGLE': - authType = 'gmail' - break - default: - break - } - const identity = { - company: user.organisations[0]?.name || '', - firstname: user.last_name, - lastname: user.first_name, - type: authType, - username: user.email, - } - Reo.identify(identity) - }) - } - if (Project.amplitude) { - amplitude.setUserId(id) - API.trackTraits({ - email: id, - }) - if (typeof window.engagement !== 'undefined') { - window.engagement.boot({ - integrations: [ - { - track: (event) => { - amplitude.track(event.event_type, event.event_properties) - }, - }, - ], - user: { - user_id: id, - user_properties: {}, - }, - }) - } - } - API.flagsmithIdentify() - }, - flagsmithIdentify() { - const user = AccountStore.model - if (!user) { - return - } - - flagsmith - .identify(user.id, { - email: user.email, - organisations: user.organisations - ? user.organisations.map((o) => `"${o.id}"`).join(',') - : '', - }) - .then(() => { - return flagsmith.setTrait( - 'logins', - (flagsmith.getTrait('logins') || 0) + 1, - ) - }) - .then(() => { - const organisation = AccountStore.getOrganisation() - const emailDomain = `${user?.email}`?.split('@')[1] || '' - const freeDomain = isFreeEmailDomain(emailDomain) - if ( - !freeDomain && - typeof delighted !== 'undefined' && - flagsmith.hasFeature('delighted') - ) { - delighted.survey({ - createdAt: user.date_joined || new Date().toISOString(), - email: user.email, - name: `${getUserDisplayName(user)}`, // time subscribed (optional) - properties: { - company: organisation?.name, - }, - }) - } - }) - }, - getCookie(key) { - const res = require('js-cookie').get(key) - if (res) { - //reset expiry - API.setCookie(key, res) - } - return res - }, - getEvent() { - return API.getCookie('event') - }, - getInvite() { - return require('js-cookie').get('invite') - }, - getInviteType() { - return require('js-cookie').get('invite-type') || 'NO_INVITE' - }, - getRedirect() { - return API.getCookie('redirect') - }, - getReferrer() { - const r = require('js-cookie').get('r') - try { - return JSON.parse(r) - } catch (e) { - return null - } - }, - identify(id, user = {}) { - if (Project.excludeAnalytics?.includes(id)) return - try { - const planNames = { - enterprise: 'Enterprise', - free: 'Free', - scaleUp: 'Scale-Up', - startup: 'Startup', - } - - // Todo: this duplicates functionality in utils.tsx however it would create a circular dependency at the moment - // we should split out these into a standalone file - const getPlanName = (plan) => { - if (plan && plan.includes('free')) { - return planNames.free - } - if (plan && plan.includes('scale-up')) { - return planNames.scaleUp - } - if (plan && plan.includes('startup')) { - return planNames.startup - } - if (plan && plan.includes('start-up')) { - return planNames.startup - } - if ( - global.flagsmithVersion?.backend.is_enterprise || - (plan && plan.includes('enterprise')) - ) { - return planNames.enterprise - } - return planNames.free - } - - const orgsByPlan = groupBy(AccountStore.getOrganisations(), (org) => - getPlanName(org?.subscription?.plan), - ) - //Picks the organisation with the highest plan - const selectedOrg = - orgsByPlan?.[planNames.enterprise]?.[0] || - orgsByPlan?.[planNames.scaleUp]?.[0] || - orgsByPlan?.[planNames.startup]?.[0] || - orgsByPlan?.[planNames.free]?.[0] - const selectedPlanName = Utils.getPlanName( - selectedOrg?.subscription?.plan, - ) - const selectedRole = selectedOrg?.role //ADMIN | USER - const selectedOrgName = selectedOrg?.name - - API.trackTraits({ - email: id, - integrations: user.onboarding?.tools?.integrations || [], - name: { 'first': user.first_name, 'last': user.last_name }, - organisation: selectedOrgName, - plan: selectedPlanName, - role: selectedRole, - tasks: (user.onboarding?.tasks || [])?.map((v) => v.name), - }) - API.flagsmithIdentify() - } catch (e) { - console.error('Error identifying', e) - } - }, - log() { - console.log.apply(this, arguments) - }, - postEvent(event, tag) { - if (!AccountStore.getUser()) return - const organisation = AccountStore.getOrganisation() - const name = - organisation && organisation.name ? ` - ${organisation.name}` : '' - return data.post('/api/event', { - event: `${event}(${AccountStore.getUser().email} ${ - AccountStore.getUser().first_name - } ${AccountStore.getUser().last_name})${name}`, - tag, - }) - }, - reset() { - return flagsmith.logout() - }, - setCookie(key, v) { - try { - if (!v) { - require('js-cookie').remove(key, { - domain: Project.cookieDomain, - path: '/', - }) - require('js-cookie').remove(key, { path: '/' }) - } else { - if (E2E) { - // Since E2E is not https, we can't set secure cookies - require('js-cookie').set(key, v, { expires: 30, path: '/' }) - } else { - // We need samesite secure cookies to allow for IFrame embeds from 3rd parties - require('js-cookie').set(key, v, { - expires: 30, - path: '/', - sameSite: Project.cookieSameSite || 'none', - secure: Project.useSecureCookies, - }) - } - } - } catch (e) {} - }, - setEvent(v) { - return API.setCookie('event', v) - }, - setInvite(id) { - const cookie = require('js-cookie') - cookie.set('invite', id) - }, - setInviteType(id) { - const cookie = require('js-cookie') - cookie.set('invite-type', id) - }, - setRedirect(v) { - return API.setCookie('redirect', v) - }, - trackEvent(data) { - if (Project.ga) { - if (Project.logAnalytics) { - console.log('ANALYTICS EVENT', data) - } - if (!data) { - console.error('Passed null event data') - } - console.info('track', data) - if (!data || !data.category || !data.event) { - console.error('Invalid event provided', data) - } - if (data.category === 'First') { - API.postEvent( - data.event + (data.extra ? ` ${data.extra}` : ''), - 'first_events', - ) - } - ga('send', { - eventAction: data.event, - eventCategory: data.category, - eventLabel: data.label, - hitType: 'event', - }) - } - - if (Project.amplitude) { - const eventData = { - category: data.category, - ...(data.extra || {}), - } - - amplitude.track(data.event, eventData) - } - }, - trackPage(title) { - if (Project.ga) { - ga('send', { - hitType: 'pageview', - location: document.location.href, - page: document.location.pathname, - title, - }) - } - }, - trackTraits(traits) { - if (Project.amplitude && traits) { - const identifyObj = new amplitude.Identify() - for (const [key, value] of Object.entries(traits)) { - identifyObj.set(key, value) - } - amplitude.identify(identifyObj) - } - }, -} - -export default API diff --git a/frontend/web/project/api.ts b/frontend/web/project/api.ts new file mode 100644 index 000000000000..009ac444f468 --- /dev/null +++ b/frontend/web/project/api.ts @@ -0,0 +1,330 @@ +// @ts-ignore +import data from 'common/data/base/_data' +// @ts-ignore +import { loadReoScript, ReoInstance } from 'reodotdev' +import * as amplitude from '@amplitude/analytics-browser' +import isFreeEmailDomain from 'common/utils/isFreeEmailDomain' +import { groupBy } from 'lodash' +import getUserDisplayName from 'common/utils/getUserDisplayName' +const Cookies = require('js-cookie') +import Project from 'common/project' +import { AccountModel, User } from 'common/types/responses' +import AccountStore from 'common/stores/account-store' +import flagsmith from 'flagsmith' +import Utils from 'common/utils/utils' + +const API = { + ajaxHandler( + store: { error?: any; goneABitWest: () => void }, + res: string | Error | { data?: any; text?: () => Promise }, + ): void { + if (typeof res === 'string') { + store.error = new Error(res) + store.goneABitWest() + return + } + if (res instanceof Error) { + console.error(res) + store.error = res + store.goneABitWest() + return + } + if (res.data) { + store.error = res.data + store.goneABitWest() + return + } + if (typeof (res as any).text !== 'function') { + store.error = res + store.goneABitWest() + return + } + + ;(res as any) + .text() + .then((errorText: string) => { + let err: any = errorText + try { + err = JSON.parse(errorText) + } catch {} + store.error = err + store.goneABitWest() + }) + .catch(() => { + if (store) { + store.goneABitWest() + } + }) + }, + + alias(id: string, user: Partial = {}): void { + if (Project.excludeAnalytics?.includes(id)) return + + Utils.setupCrisp() + if (Project.reo) { + loadReoScript({ clientID: Project.reo }).then( + (Reo: typeof ReoInstance) => { + Reo.init({ clientID: Project.reo! }) + let authType = 'userID' + switch (user.auth_type) { + case 'EMAIL': + authType = 'email' + break + case 'GITHUB': + authType = 'github' + break + case 'GOOGLE': + authType = 'gmail' + break + default: + break + } + Reo.identify({ + company: user.organisations?.[0]?.name || '', + firstname: user.last_name ?? '', + lastname: user.first_name ?? '', + type: authType, + username: user.email ?? '', + }) + }, + ) + } + + if (Project.amplitude) { + amplitude.setUserId(id) + API.trackTraits({ email: id }) + if (window.engagement) { + window.engagement.boot({ + integrations: [ + { + track: (event: any) => + amplitude.track(event.event_type, event.event_properties), + }, + ], + user: { user_id: id, user_properties: {} }, + }) + } + } + API.flagsmithIdentify() + }, + + flagsmithIdentify(): void { + //@ts-ignore + const user = AccountStore.model as unknown as AccountModel + if (!user) return + + flagsmith + .identify(`${user.id}`, { + email: user.email, + organisations: user.organisations + ? user.organisations.map((o) => String(o.id)).join(',') + : '', + }) + .then(() => + flagsmith.setTrait( + 'logins', + ((flagsmith.getTrait('logins') as number) || 0) + 1, + ), + ) + .then(() => { + const organisation = AccountStore.getOrganisation() + const emailDomain = user.email.split('@')[1] || '' + const freeDomain = isFreeEmailDomain(emailDomain) + if ( + !freeDomain && + typeof delighted !== 'undefined' && + flagsmith.hasFeature('delighted') + ) { + delighted.survey({ + createdAt: user.date_joined || new Date().toISOString(), + email: user.email, + name: getUserDisplayName(user), + properties: { company: organisation?.name }, + }) + } + }) + }, + + getCookie(key: string): string | undefined { + const val = Cookies.get(key) + if (val) API.setCookie(key, val) + return val + }, + + getEvent(): string | undefined { + return API.getCookie('event') + }, + + getInvite(): string | undefined { + return Cookies.get('invite') + }, + + getInviteType(): string { + return Cookies.get('invite-type') || 'NO_INVITE' + }, + + getRedirect(): string | undefined { + return API.getCookie('redirect') + }, + + getReferrer(): any { + const r = Cookies.get('r') + try { + return JSON.parse(r!) + } catch { + return null + } + }, + + identify(id: string | number, user: Partial = {}): void { + if (Project.excludeAnalytics?.includes(String(id))) return + try { + const planNames = { + enterprise: 'Enterprise', + free: 'Free', + scaleUp: 'Scale-Up', + startup: 'Startup', + } + const getPlanName = (plan?: string): string => { + if (!plan) return planNames.free + if (plan.includes('free')) return planNames.free + if (plan.includes('scale-up')) return planNames.scaleUp + if (plan.includes('startup') || plan.includes('start-up')) + return planNames.startup + if (Project.backend?.is_enterprise || plan.includes('enterprise')) + return planNames.enterprise + return planNames.free + } + + const orgsByPlan = groupBy(AccountStore.getOrganisations(), (org) => + getPlanName(org.subscription?.plan || ''), + ) + const selectedOrg = + orgsByPlan.Enterprise?.[0] || + orgsByPlan['Scale-Up']?.[0] || + orgsByPlan.Startup?.[0] || + orgsByPlan.Free?.[0] + const selectedPlanName = Utils.getPlanName?.( + selectedOrg?.subscription?.plan || '', + ) + const selectedRole = selectedOrg?.role + const selectedOrgName = selectedOrg?.name + + API.trackTraits({ + email: String(id), + integrations: user.onboarding?.tools?.integrations || [], + name: { first: user.first_name, last: user.last_name }, + organisation: selectedOrgName, + plan: selectedPlanName, + role: selectedRole, + tasks: user.onboarding?.tasks?.map((t) => t.name) || [], + }) + API.flagsmithIdentify() + } catch (err) { + console.error('Error identifying', err) + } + }, + + log(...args: any[]): void { + console.log(...args) + }, + + postEvent(event: string, tag?: string): Promise | void { + const currentUser = AccountStore.getUser() + if (!currentUser) return + const organisation = AccountStore.getOrganisation() + const name = organisation?.name ? ` - ${organisation.name}` : '' + return data.post('/api/event', { + event: `${event}(${currentUser.email} ${currentUser.first_name} ${currentUser.last_name})${name}`, + tag, + }) + }, + + reset(): Promise { + return flagsmith.logout() + }, + + setCookie(key: string, v?: string): void { + if (!v) { + Cookies.remove(key, { domain: Project.cookieDomain, path: '/' }) + Cookies.remove(key, { path: '/' }) + } else { + const opts = { expires: 30, path: '/' } + if (!E2E) + Object.assign(opts, { + sameSite: Project.cookieSameSite || 'none', + secure: Project.useSecureCookies, + }) + Cookies.set(key, v, opts) + } + }, + + setEvent(v: string): void { + API.setCookie('event', v) + }, + + setInvite(id: string): void { + Cookies.set('invite', id) + }, + + setInviteType(id: string): void { + Cookies.set('invite-type', id) + }, + + setRedirect(v: string): void { + API.setCookie('redirect', v) + }, + + trackEvent(data: { + category: string + event: string + label?: string + extra?: Record + }): void { + if (Project.ga) { + if (Project.logAnalytics) console.log('ANALYTICS EVENT', data) + if (!data || !data.category || !data.event) + return console.error('Invalid event provided', data) + if (data.category === 'First') + API.postEvent( + `${data.event}${data.extra ? ` ${JSON.stringify(data.extra)}` : ''}`, + 'first_events', + ) + ga('send', { + eventAction: data.event, + eventCategory: data.category, + eventLabel: data.label, + hitType: 'event', + }) + } + if (Project.amplitude) { + amplitude.track(data.event, { + category: data.category, + ...(data.extra || {}), + }) + } + }, + + trackPage(title: string): void { + if (Project.ga) + ga('send', { + hitType: 'pageview', + location: document.location.href, + page: document.location.pathname, + title, + }) + }, + + trackTraits(traits: Record): void { + if (Project.amplitude && traits) { + const identifyObj = new amplitude.Identify() + Object.entries(traits).forEach(([key, value]) => + identifyObj.set(key, value), + ) + amplitude.identify(identifyObj) + } + }, +} +//@ts-ignore //todo: remove global usages / circular dependencies of API +global.API = API +export default API